"""A 股 Deep Research 统一 CLI。

用法:
    uv run a-share crawl                     # M1 抓取
    uv run a-share extract --date 20260616   # M2 提取
    uv run a-share dedup --date 20260616     # M3 去重
    uv run a-share events --date 20260616    # M4 LLM 抽取
    uv run a-share embed --date 20260616     # M5 向量化
    uv run a-share ingest --date 20260616    # M6 入库
    uv run a-share pipeline --once           # 全链路
    uv run a-share discover https://xxx.com  # 分析站点,推荐配置
    uv run a-share report                    # 生成日报
    uv run a-share search "宁德时代"          # 检索知识库
    uv run a-share status                    # 状态总览
"""

from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
from collections import Counter
from datetime import date, datetime
from pathlib import Path
from typing import Any

from dotenv import load_dotenv
from loguru import logger

# --------------------------------------------------------------------------- #
# 工具
# --------------------------------------------------------------------------- #

# --------------------------------------------------------------------------- #
# 操作日志
# --------------------------------------------------------------------------- #

_OP_LOG_PATH = Path("logs") / "operations.log"

def _log_operation_start(command: str, detail: str = "") -> None:
    """记录操作开始。"""
    _OP_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] START  | {command:20} | {detail}".rstrip()
    with _OP_LOG_PATH.open("a", encoding="utf-8") as f:
        f.write(line + "\n")

def _log_operation_end(command: str, success: bool, elapsed: float, detail: str = "") -> None:
    """记录操作结束。"""
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    status = "OK" if success else "FAIL"
    detail_str = f" | {detail}" if detail else ""
    line = f"[{ts}] END    | {command:20} | {status} | {elapsed:.1f}s{detail_str}"
    with _OP_LOG_PATH.open("a", encoding="utf-8") as f:
        f.write(line + "\n")

def _wrap_cmd(name: str, func, *cmd_args: object) -> int:
    """包装命令函数,自动记录开始/结束日志。"""
    from time import perf_counter
    detail_parts = []
    for a in cmd_args:
        if a is not None and a is not False and a != "" and a != 0:
            detail_parts.append(str(a)[:80])
    detail = " ".join(detail_parts) if detail_parts else ""
    _log_operation_start(name, detail)
    started = perf_counter()
    try:
        rc = func()
        elapsed = perf_counter() - started
        _log_operation_end(name, rc == 0, elapsed)
        return rc
    except Exception as e:
        elapsed = perf_counter() - started
        _log_operation_end(name, False, elapsed, f"{type(e).__name__}: {e}")
        raise


def _setup_logger(level: str = "WARNING") -> None:
    logger.remove()
    logger.add(sys.stderr, level=level, format="{level} | {message}")


def _today() -> str:
    return date.today().strftime("%Y%m%d")


def _resolve_timeout(step_name: str, hardcoded_default: int) -> int:
    """解析步骤超时(秒),与 scheduler.pipeline 保持一致的优先级。

    优先级:
        1. TIMEOUT_{STEP_NAME} 环境变量
        2. PIPELINE_STEP_TIMEOUT 环境变量 (全局兜底)
        3. 硬编码默认值 (本函数参数)
    """
    import os
    specific_key = f"TIMEOUT_{step_name.upper()}"
    if specific_key in os.environ:
        return int(os.environ[specific_key])
    if "PIPELINE_STEP_TIMEOUT" in os.environ:
        return int(os.environ["PIPELINE_STEP_TIMEOUT"])
    return hardcoded_default


def _run_module(module: str, extra_args: list[str], timeout: int = 600) -> int:
    """调用现有 scripts/run_*.py 模块。"""
    cmd = ["uv", "run", "python", "-m", module, *extra_args]
    logger.info("执行: {}", " ".join(cmd))
    try:
        result = subprocess.run(cmd, timeout=timeout)
        return result.returncode
    except subprocess.TimeoutExpired:
        logger.error("超时 ({}s): {}", timeout, " ".join(cmd))
        return 1


# --------------------------------------------------------------------------- #
# 子命令
# --------------------------------------------------------------------------- #

def cmd_crawl(args: argparse.Namespace) -> int:
    extra = []
    if args.source:
        extra.extend(["--source", args.source])
    if args.no_save:
        extra.append("--no-save")
    return _run_module("scripts.run_crawler", extra, timeout=_resolve_timeout("crawler", 600))


def cmd_extract(args: argparse.Namespace) -> int:
    extra = ["--date", args.date or _today()]
    if args.source:
        extra.extend(["--source", args.source])
    return _run_module("scripts.run_extractor", extra, timeout=_resolve_timeout("extractor", 300))


def cmd_dedup(args: argparse.Namespace) -> int:
    extra = ["--date", args.date or _today()]
    if args.reset:
        extra.append("--reset")
    return _run_module("scripts.run_dedup", extra, timeout=_resolve_timeout("dedup", 120))


def cmd_events(args: argparse.Namespace) -> int:
    extra = ["--date", args.date or _today()]
    if args.provider:
        extra.extend(["--provider", args.provider])
    if args.model:
        extra.extend(["--model", args.model])
    if args.limit:
        extra.extend(["--limit", str(args.limit)])
    if args.concurrency:
        extra.extend(["--concurrency", str(args.concurrency)])
    return _run_module("scripts.run_event_extraction", extra, timeout=_resolve_timeout("llm", 900))


def cmd_embed(args: argparse.Namespace) -> int:
    extra = ["--date", args.date or _today()]
    if args.provider:
        extra.extend(["--provider", args.provider])
    if args.model:
        extra.extend(["--model", args.model])
    return _run_module("scripts.run_embedding", extra, timeout=_resolve_timeout("embedding", 300))


def cmd_ingest(args: argparse.Namespace) -> int:
    extra = ["--date", args.date or _today()]
    if args.recreate:
        extra.append("--recreate")
    return _run_module("scripts.run_qdrant_ingest", extra, timeout=_resolve_timeout("qdrant", 120))


def cmd_pipeline(args: argparse.Namespace) -> int:
    pipeline_timeout = _resolve_timeout("pipeline", 3600)
    if args.cninfo_once:
        extra = ["--once", "--date", args.date or _today(),
                 "--steps", "cninfo_crawl,cninfo_extract,cninfo_pdf,dedup,llm,embedding,qdrant"]
        return _run_module("scripts.run_scheduler", extra, timeout=pipeline_timeout)
    if args.once:
        extra = ["--once", "--date", args.date or _today()]
        steps = args.steps
        if args.report:
            steps = (steps + ",report") if steps else "report"
        if steps:
            extra.extend(["--steps", steps])
        return _run_module("scripts.run_scheduler", extra, timeout=pipeline_timeout)
    # 守护进程模式：不应通过 CLI 子进程启动（subprocess.run 无法正确管理后台进程）。
    # 直接在当前进程启动 APScheduler。
    print("🔁 启动守护进程模式 (APScheduler) …")
    print("   提示: 生产环境请使用 systemd 管理，见 docs/systemd.md")
    from scripts.run_scheduler import _daemon as _run_daemon
    # 构造一个简易的 namespace 给 _daemon
    daemon_args = argparse.Namespace()
    return _run_daemon(daemon_args)


# --------------------------------------------------------------------------- #
# search — 直接从终端检索知识库
# --------------------------------------------------------------------------- #

def cmd_search(args: argparse.Namespace) -> int:
    """嵌入 query → Qdrant 检索 → 格式化输出。"""
    load_dotenv()
    _setup_logger("WARNING")

    from embedding import make_sync_provider
    from vectorstore import SearchFilter, VectorStore, make_qdrant_client

    # 嵌入查询
    print(f"🔍 检索: {args.query}")
    emb = make_sync_provider()  # 读取 EMBEDDING_PROVIDER 环境变量
    vec = emb.embed_one(args.query)

    # 构建过滤
    filt = None
    has_filter = any([
        args.source, args.sentiment, args.min_importance,
        args.stock, args.industry,
    ])
    if has_filter:
        kwargs: dict[str, Any] = {}
        if args.source:
            kwargs["source_id"] = args.source
        if args.sentiment:
            kwargs["sentiment"] = args.sentiment
        if args.min_importance:
            kwargs["importance_min"] = args.min_importance
        if args.stock:
            # 股票代码可能带或不带后缀,两者都匹配
            code = args.stock.strip().upper()
            stock_list = [code] if "." in code else [f"{code}.SZ", f"{code}.SH", f"{code}.BJ"]
            kwargs["stock_codes"] = stock_list
        if args.industry:
            kwargs["industries"] = [args.industry]
        filt = SearchFilter(**kwargs)

    # 检索
    client = make_qdrant_client()
    store = VectorStore(client)
    hits = store.query(query_vector=vec, top_k=args.top, filter=filt, score_threshold=0.2)
    store.close()
    emb.close()

    if not hits:
        print(f"\n未找到与「{args.query}」相关的结果。")
        return 0

    print(f"\n共 {len(hits)} 条结果:\n")
    for i, h in enumerate(hits, 1):
        ev = h.event or {}
        sentiment_icon = {"positive": "🟢", "neutral": "⚪", "negative": "🔴"}.get(
            ev.get("sentiment"), ""
        )
        print(f"{i}. {sentiment_icon} {h.title}")
        print(f"   来源: {h.source_id} | 相似度: {h.score:.4f} | 时间: {h.publish_time}")
        if ev.get("stock_codes"):
            print(f"   代码: {','.join(ev['stock_codes'])}")
        if ev.get("event_type"):
            print(f"   事件: {ev.get('event_type')} (重要度 {ev.get('importance', '-')})")
        if ev.get("summary"):
            print(f"   摘要: {ev['summary']}")
        print(f"   {h.url}")
        print()

    return 0


# --------------------------------------------------------------------------- #
# status — 数据总览
# --------------------------------------------------------------------------- #

def cmd_status(args: argparse.Namespace) -> int:  # noqa: ARG001
    """输出各层数据统计。"""
    load_dotenv()
    today_str = _today()

    def _count_jsonl(path: Path) -> int:
        if not path.is_file():
            return 0
        return sum(1 for _ in open(path, encoding="utf-8"))

    def _count_dir(pattern: str) -> int:
        return len(list(Path().glob(pattern)))

    def _count_json(pattern: str) -> int:
        return len(list(Path().glob(pattern)))

    print(f"📊 A 股 Deep Research 状态 — {today_str}")
    print("─" * 50)

    # M1 raw
    raw_art = 0
    for idx in Path("data/raw").glob(f"*/{today_str}/index.jsonl"):
        raw_art += _count_jsonl(idx)
    print(f"  M1 抓取: {raw_art} 篇原始文章")

    # M2 processed
    proc = _count_json(f"data/processed/*/{today_str}/*.json")
    print(f"  M2 提取: {proc} 篇正文")

    # M3 deduped
    deduped = _count_json(f"data/deduped/{today_str}/uniques/*.json")
    dup_path = Path(f"data/deduped/{today_str}/duplicates.jsonl")
    dups = _count_jsonl(dup_path) if dup_path.is_file() else 0
    print(f"  M3 去重: {deduped} 篇唯一, {dups} 篇重复")

    # M4 events
    events = _count_json(f"data/events/{today_str}/*.json")
    if events > 0:
        sentiments: Counter = Counter()
        importances: Counter = Counter()
        for fp in Path(f"data/events/{today_str}").glob("*.json"):
            try:
                obj = json.loads(fp.read_text(encoding="utf-8"))
                ev = obj.get("event", {})
                sentiments[ev.get("sentiment", "?")] += 1
                importances[ev.get("importance", 0)] += 1
            except (json.JSONDecodeError, OSError):
                pass
        high = sum(v for k, v in importances.items() if k >= 4)
        print(f"  M4 事件: {events} 篇 "
              f"(🟢{sentiments.get('positive', 0)} "
              f"🔴{sentiments.get('negative', 0)} "
              f"⚪{sentiments.get('neutral', 0)}, "
              f"重要≥4: {high})")

    # M5 embeddings
    emb_count = _count_json(f"data/embeddings/{today_str}/*.json")
    print(f"  M5 向量: {emb_count} 条")

    # cninfo 公告
    cninfo_raw = 0
    for idx in Path("data/raw/cninfo").glob("*/index.jsonl"):
        cninfo_raw += _count_jsonl(idx)
    if cninfo_raw > 0:
        cninfo_proc = _count_json("data/processed/cninfo/*/*.json")
        print(f"  cninfo: {cninfo_raw} 条公告 (已提取 {cninfo_proc})")
    else:
        print("  cninfo: 暂无数据")

    # M6 Qdrant
    try:
        from vectorstore import VectorStore, make_qdrant_client
        c = make_qdrant_client()
        s = VectorStore(c)
        total = s.count()
        s.close()
        print(f"  M6 Qdrant: {total} 条向量")
    except Exception:
        print("  M6 Qdrant: 未连接")

    # M7 systemd
    try:
        r = subprocess.run(
            ["systemctl", "is-active", "a-share-research"],
            capture_output=True, text=True, timeout=5,
        )
        svc = r.stdout.strip()
        icon = "✅" if svc == "active" else "❌"
        print(f"  M7 调度: {icon} {svc}")
    except Exception:
        pass

    print("─" * 50)

    # 高重要度事件
    if events > 0:
        print("\n🔥 今日重要度 ≥4 的事件:")
        high_events: list[dict] = []
        for fp in Path(f"data/events/{today_str}").glob("*.json"):
            try:
                obj = json.loads(fp.read_text(encoding="utf-8"))
                ev = obj.get("event", {})
                if ev.get("importance", 0) >= 4:
                    high_events.append({
                        "title": obj.get("title", ""),
                        "source": obj.get("source_id", ""),
                        "sentiment": ev.get("sentiment", ""),
                        "importance": ev.get("importance", 0),
                        "summary": ev.get("summary", ""),
                    })
            except (json.JSONDecodeError, OSError):
                pass
        high_events.sort(key=lambda e: -e["importance"])
        for e in high_events[:10]:
            icon = {"positive": "🟢", "negative": "🔴", "neutral": "⚪"}.get(e["sentiment"], "")
            print(f"  {icon} [{e['source']}] {e['title'][:50]} ({e['summary'][:40]})")

    return 0


# --------------------------------------------------------------------------- #
# report — 生成每日摘要 HTML 报告并上传
# --------------------------------------------------------------------------- #

def cmd_report(args: argparse.Namespace) -> int:
    """生成日报并结构化入库（M10：不再生成 HTML/上传）。"""
    load_dotenv()
    from scheduler.reporter import generate_report

    day_str = args.date or _today()
    print(f"📊 生成日报: {day_str}")
    report_id = generate_report(day_str, upload=not args.no_upload)
    if report_id is None:
        print("⚠️  无数据或生成失败")
        return 1
    print(f"✅ 日报已入库: report_id={report_id}")
    return 0


def cmd_report_import(args: argparse.Namespace) -> int:
    """历史日报 HTML 解析入库（M10）。"""
    load_dotenv()
    from report_import.importer import import_history

    report_dir = args.dir or os.environ.get("REPORT_HISTORY_DIR", "data/reports_history")
    print(f"📥 导入历史日报: {report_dir} (date={args.date or '全部'} type={args.type or '全部'})")
    stats = import_history(report_dir, date_str=args.date, report_type=args.type, force=args.force)
    print(f"✅ 扫描 {stats.scanned} | 新导入 {stats.imported} | 跳过 {stats.skipped} | 失败 {stats.failed}")
    for err in stats.errors[:20]:
        print(f"   ❌ {err}")
    return 0 if stats.failed == 0 else 1


# --------------------------------------------------------------------------- #
# discover — 自动分析站点,生成 sources.yaml 配置建议
# --------------------------------------------------------------------------- #

def cmd_discover(args: argparse.Namespace) -> int:
    """抓取首页 → 提取链接 → 按 URL 模式聚类 → 输出 yaml 配置。"""
    import re
    import urllib.parse
    from collections import defaultdict

    print(f"🔍 分析站点: {args.url}")
    print("⏳ 正在抓取首页(含 JS 渲染)...\n")

    # 1. 抓取首页
    try:
        from crawl4ai import AsyncWebCrawler, BrowserConfig, CacheMode, CrawlerRunConfig

        async def _fetch():
            bconf = BrowserConfig(headless=True, verbose=False)
            rconf = CrawlerRunConfig(cache_mode=CacheMode.BYPASS, page_timeout=30000)
            async with AsyncWebCrawler(config=bconf) as crawler:
                return await crawler.arun(url=args.url, config=rconf)

        import asyncio
        result = asyncio.run(_fetch())
    except ImportError:
        print("❌ 需要 crawl4ai 依赖,请确保已安装")
        return 1
    except Exception as e:
        print(f"❌ 抓取失败: {e}")
        return 1

    if not result or not getattr(result, "success", False):
        print(f"❌ 页面抓取失败: {getattr(result, 'error_message', 'unknown')}")
        return 1

    html = getattr(result, "html", "") or ""
    if not html:
        print("❌ 页面无 HTML 内容")
        return 1

    # 2. 提取所有链接
    from bs4 import BeautifulSoup
    soup = BeautifulSoup(html, "html.parser")
    parsed_base = urllib.parse.urlparse(args.url)
    base_domain = f"{parsed_base.scheme}://{parsed_base.netloc}"

    raw_links: list[tuple[str, str]] = []
    for a in soup.find_all("a", href=True):
        href = a["href"].strip()
        if not href or href.startswith(("javascript:", "#", "mailto:", "tel:")):
            continue
        absolute = urllib.parse.urljoin(args.url, href).split("#")[0]
        anchor = (a.get_text() or "").strip()[:40]
        raw_links.append((absolute, anchor))

    # 3. 过滤出站内链接,排除导航/静态页
    site_links: list[tuple[str, str]] = []
    for url, anchor in raw_links:
        if base_domain not in url:
            continue
        # 排除明显的导航/静态页
        path = urllib.parse.urlparse(url).path.lower()
        if path in ("/", "") or any(path.endswith(ext) for ext in (".css", ".js", ".png", ".jpg", ".ico", ".svg", ".xml", ".pdf")):
            continue
        site_links.append((url, anchor))

    if not site_links:
        print("❌ 未发现站内链接(可能需要 JS 渲染或页面结构特殊)")
        print("   可尝试手动打开浏览器开发者工具查看网络请求")
        return 1

    unique_links = list(dict.fromkeys(site_links))  # 去重保序

    # 4. 过滤导航/功能页
    _nav_words = (
        "about", "download", "feedback", "member", "login", "register", "app",
        "calendar", "help", "contact", "privacy", "terms", "service", "buy",
        "markets", "codes", "real", "lives",
    )
    article_links: list[tuple[str, str]] = []
    for url, anchor in unique_links:
        path_lower = urllib.parse.urlparse(url).path.lower()
        parts_lower = [p for p in path_lower.split("/") if p]
        # 跳过明显的导航/功能路径(子串匹配)
        if any(nav in p for nav in _nav_words for p in parts_lower if len(p) > 2):
            continue
        article_links.append((url, anchor))

    if not article_links:
        # 降级:不过滤
        article_links = unique_links

    # 5. 按 URL 路径模式聚类
    clusters: dict[str, list[tuple[str, str]]] = defaultdict(list)
    for url, anchor in article_links:
        path = urllib.parse.urlparse(url).path.strip("/")
        # 把数字/日期/hash 替换为占位符进行聚类
        pattern = re.sub(r"/\d{4,}", "/{id}", path)
        pattern = re.sub(r"/[a-f0-9]{32,}", "/{hash}", pattern)
        pattern = re.sub(r"/\d{4}-\d{2}-\d{2}", "/{date}", pattern)
        # 恢复目录结构作为聚类键
        parts = pattern.split("/")
        # 聚类键:用路径前缀(前两级目录) + 最后一段的模式
        key = "/".join(parts[:2]) + "/{...}" if len(parts) >= 2 else (parts[0] if parts else "/")
        clusters[key].append((url, anchor))

    # 过滤只有 1 条的聚类
    clusters = {k: v for k, v in clusters.items() if len(v) >= 2}
    if not clusters:
        print("⚠️  未发现明显的文章链接模式(每个 URL 路径都不同)")
        print("   可手动检查页面结构")
        return 1

    # 6. 按链接数排序,但优先数字 ID 模式(文章特征)
    def _score(kv: tuple) -> tuple:
        _key, _links = kv
        has_num_id = bool(re.search(r"/\d{4,}", _links[0][0]))  # URL 含长数字
        article_word = any(w in _key.lower() for w in ("article", "news", "detail", "story"))
        return (has_num_id or article_word, len(_links))

    ranked = sorted(clusters.items(), key=_score, reverse=True)

    # 6. 对每个聚类生成正则
    site_host = urllib.parse.urlparse(args.url).netloc.replace(".", r"\.")
    results: list[dict] = []
    for cluster_key, links in ranked:
        # 从实际 URL 中提取路径前缀来构建更精确的正则
        sample_paths = [urllib.parse.urlparse(u).path for u, _ in links[:5]]
        # 找路径公共前缀
        common_prefix = _common_path_prefix(sample_paths)
        # 推测数字部分
        if re.search(r"/\d+", sample_paths[0]):
            pattern_re = f"^https?://{site_host}{re.escape(common_prefix)}\\d+"
        else:
            pattern_re = f"^https?://{site_host}{re.escape(common_prefix)}.*"
        # 去掉多余的转义
        pattern_re = pattern_re.replace(r"\d+", r"\d+")
        results.append({
            "key": cluster_key,
            "count": len(links),
            "regex": pattern_re,
            "samples": links[:3],
        })

    # 7. 输出结果
    for i, r in enumerate(results, 1):
        stars = "★★★" if i == 1 else ("★★" if i == 2 else "★")
        print(f"模式 {chr(64+i)} ({r['count']} 条, 推荐 {stars}):")
        print(f"  正则: {r['regex']}")
        print("  样本:")
        for url, anchor in r["samples"]:
            print(f"    {url}" + (f"  [{anchor}]" if anchor else ""))

    # 8. 输出 yaml 建议
    best = results[0]
    source_id = parsed_base.netloc.split(".")[0].replace("-", "_")
    print()
    print("─" * 60)
    print("建议 yaml 配置(复制到 configs/sources.yaml):\n")
    yaml_snippet = f"""  - id: {source_id}
    name: {source_id}
    enabled: true
    homepage: {args.url}
    article_url_pattern: '{best['regex']}'
    js_render: {str(not getattr(result, 'markdown', '')).lower() if hasattr(result, 'markdown') else 'true'}
    wait_for: "css:body"
    page_timeout_ms: 30000
    max_articles_per_run: 20"""
    source_name = args.name or source_id
    yaml_snippet = yaml_snippet.replace(f"name: {source_id}", f"name: {source_name}")

    # 有额外入口时,追加 extra_homepages 字段
    if args.extra_urls:
        extra_lines = "\n".join(f"      - {u}" for u in args.extra_urls)
        yaml_snippet += f"\n    extra_homepages:\n{extra_lines}"

    print(yaml_snippet)
    print("─" * 60)

    if args.add:
        yaml_path = Path("configs/sources.yaml")
        if yaml_path.is_file():
            content = yaml_path.read_text(encoding="utf-8")
            if f"id: {source_id}" in content:
                print(f"\n⚠️  sources.yaml 中已存在 id={source_id},跳过添加")
            else:
                seq = content.count("\n  - id:") + 1
                header = f"\n  # ---- {seq}. {source_name} ----"
                yaml_path.write_text(content.rstrip() + "\n" + header + "\n" + yaml_snippet + "\n", encoding="utf-8")
                print(f"\n✅ 已追加到 configs/sources.yaml (第 {seq} 个源)")
        else:
            print("\n⚠️  configs/sources.yaml 不存在,无法自动添加")

    print(f"\n验证: uv run a-share crawl --source {source_id}")
    return 0


# --------------------------------------------------------------------------- #
# add-entry — 为已有源追加 extra_homepages
# --------------------------------------------------------------------------- #

def cmd_add_entry(args: argparse.Namespace) -> int:
    """给已有源追加 extra_homepages 入口(文本模式,保留 yaml 原有格式)。"""
    import re

    yaml_path = Path("configs/sources.yaml")
    if not yaml_path.is_file():
        print("❌ configs/sources.yaml 不存在")
        return 1

    content = yaml_path.read_text(encoding="utf-8")

    if f"id: {args.source}" not in content:
        print(f"❌ 未找到源 id={args.source}")
        return 1

    # 提取该 source 块中已有的 extra_homepages URL
    lines = content.splitlines()
    in_block = False
    existing: set[str] = set()
    for line in lines:
        if f"id: {args.source}" in line and line.strip().startswith("- id:"):
            in_block = True
            continue
        if in_block and (line.strip().startswith("- id:") or line.strip().startswith("settings:")):
            break
            m = re.match(r"\s+- (https?://\S+)", line)
            if m:
                existing.add(m.group(1))

    added = []
    seen_this_run: set[str] = set()
    for url in args.urls:
        if url in existing or url in seen_this_run:
            print(f"⏭  跳过(已存在): {url}")
        else:
            seen_this_run.add(url)
            added.append(url)
            print(f"✅ 已添加: {url}")

    if not added:
        print("无新增入口")
        return 0

    # 在 source 块内插入/追加
    has_extra_header = any("extra_homepages:" in lines[i] for i in range(len(lines)) if in_block_from(lines, i, args.source))

    if has_extra_header:
        # 找到块内最后一个 extra URL 行,在其后追加
        insert_at = -1
        for i in range(len(lines)):
            if in_block_from(lines, i, args.source) and re.match(r"\s+- https?://", lines[i]) and "extra_homepages:" in "\n".join(lines[max(0, i - 2):i + 1]):
                insert_at = i
        if insert_at > 0:
            indent = "      "
            for u in added:
                lines.insert(insert_at + 1, f"{indent}- {u}")
                insert_at += 1
    else:
        # 在 max_articles_per_run 行后插入 extra_homepages
        for i in range(len(lines)):
            if in_block_from(lines, i, args.source) and "max_articles_per_run:" in lines[i]:
                indent = "    "
                new_block = [f"{indent}extra_homepages:"]
                for u in added:
                    new_block.append(f"{indent}  - {u}")
                for j, nl in enumerate(new_block):
                    lines.insert(i + 1 + j, nl)
                break

    yaml_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    print(f"\n✅ 已写入 configs/sources.yaml ({len(added)} 个新入口)")
    return 0


def in_block_from(lines: list[str], idx: int, source_id: str) -> bool:
    """检查行 idx 是否在 source_id 的配置块内。"""
    for i in range(idx, -1, -1):
        if lines[i].strip().startswith(f"- id: {source_id}"):
            return True
        if lines[i].strip().startswith("- id:") or lines[i].strip().startswith("settings:"):
            return False
    return False


# --------------------------------------------------------------------------- #
# watchlist 管理
# --------------------------------------------------------------------------- #

_WATCHLIST_PATH = Path("configs/watchlist.yaml")


def _load_watchlist() -> list[dict]:
    import yaml
    try:
        with _WATCHLIST_PATH.open(encoding="utf-8") as f:
            data = yaml.safe_load(f) or {}
        return list(data.get("watchlist") or [])
    except Exception:
        return []


def _save_watchlist(items: list[dict]) -> None:
    import yaml
    _WATCHLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
    with _WATCHLIST_PATH.open("w", encoding="utf-8") as f:
        yaml.dump({"watchlist": items}, f, allow_unicode=True, default_flow_style=False, sort_keys=False)


def cmd_watchlist_add(args: argparse.Namespace) -> int:
    items = _load_watchlist()
    code = args.code.strip()
    for it in items:
        if it.get("code") == code:
            it["name"] = args.name
            if args.note:
                it["note"] = args.note
            _save_watchlist(items)
            print(f"✅ 已更新: {code} {args.name}")
            return 0
    items.append({"code": code, "name": args.name, "note": args.note or ""})
    _save_watchlist(items)
    print(f"✅ 已添加: {code} {args.name}")
    return 0


def cmd_watchlist_remove(args: argparse.Namespace) -> int:
    items = _load_watchlist()
    code = args.code.strip()
    new_items = [it for it in items if it.get("code") != code]
    if len(new_items) == len(items):
        print(f"⚠️  未找到: {code}")
        return 1
    _save_watchlist(new_items)
    print(f"✅ 已移除: {code}")
    return 0


def cmd_watchlist_list(args: argparse.Namespace) -> int:  # noqa: ARG001
    items = _load_watchlist()
    if not items:
        print("📋 关注列表为空")
        print("添加: uv run a-share watchlist add 000001 平安银行")
        return 0
    print(f"📋 关注列表 ({len(items)} 家):")
    for it in items:
        note = f" — {it.get('note','')}" if it.get("note") else ""
        print(f"  {it['code']}  {it['name']}{note}")
    return 0


def cmd_stock_report(args: argparse.Namespace) -> int:
    """生成所有关注公司个股日报。"""
    from scheduler.stock_reporter import generate_all_stock_reports
    n = generate_all_stock_reports(upload=not args.no_upload)
    print(f"✅ 个股报告完成: {n} 家")
    return 0 if n > 0 else 1


def cmd_cninfo(args: argparse.Namespace) -> int:
    """cninfo watchlist 抓取(公告+调研+互动易,API 优先)。"""
    from crawler.cninfo import crawl_watchlist, enrich_articles_with_pdf

    if args.enrich_pdf:
        n = enrich_articles_with_pdf(day_str=None, limit=args.pdf_limit)
        print(f"✅ PDF 正文提取完成: {n} 篇")
        return 0 if n > 0 else 1

    # 默认: watchlist 全类型抓取(公告+调研+互动易)
    results = crawl_watchlist(save=not args.no_save)

    if not results:
        print("⚠️  cninfo 抓取: 无数据")
        return 1

    # 按类型统计
    ann = sum(1 for it in results if getattr(it, "item_type", "") == "announcement")
    res = sum(1 for it in results if getattr(it, "item_type", "") == "research")
    irm = sum(1 for it in results if getattr(it, "item_type", "") == "irm")
    print(f"✅ cninfo 抓取完成: 公告 {ann} / 调研 {res} / 互动易 {irm} (共 {len(results)} 条)")
    return 0


def _common_path_prefix(paths: list[str]) -> str:
    """找路径列表的公共前缀(逐字符)。"""
    if not paths:
        return "/"
    shortest = min(paths, key=len)
    for i, ch in enumerate(shortest):
        if any(p[i:i+1] != ch for p in paths):
            prefix = shortest[:i]
            # 截断到最后一个 /
            last_slash = prefix.rfind("/")
            return prefix[:last_slash + 1] if last_slash >= 0 else "/"
    return shortest


# --------------------------------------------------------------------------- #
# 主入口
# --------------------------------------------------------------------------- #

def main() -> int:
    load_dotenv()
    parser = argparse.ArgumentParser(
        prog="a-share",
        description="A 股 Deep Research 统一 CLI",
    )
    sub = parser.add_subparsers(dest="command", help="子命令")

    # ---- crawl ----
    p = sub.add_parser("crawl", help="M1 新闻抓取")
    p.add_argument("--source", default=None)
    p.add_argument("--no-save", action="store_true")
    p.set_defaults(func=cmd_crawl)

    # ---- extract ----
    p = sub.add_parser("extract", help="M2 正文提取")
    p.add_argument("--date", default=None)
    p.add_argument("--source", default=None)
    p.set_defaults(func=cmd_extract)

    # ---- dedup ----
    p = sub.add_parser("dedup", help="M3 三层去重")
    p.add_argument("--date", default=None)
    p.add_argument("--reset", action="store_true")
    p.set_defaults(func=cmd_dedup)

    # ---- events ----
    p = sub.add_parser("events", help="M4 LLM 事件抽取")
    p.add_argument("--date", default=None)
    p.add_argument("--provider", default=None)
    p.add_argument("--model", default=None)
    p.add_argument("--limit", type=int, default=0)
    p.add_argument("--concurrency", type=int, default=0)
    p.set_defaults(func=cmd_events)

    # ---- embed ----
    p = sub.add_parser("embed", help="M5 向量化")
    p.add_argument("--date", default=None)
    p.add_argument("--provider", default=None)
    p.add_argument("--model", default=None)
    p.set_defaults(func=cmd_embed)

    # ---- ingest ----
    p = sub.add_parser("ingest", help="M6 Qdrant 入库")
    p.add_argument("--date", default=None)
    p.add_argument("--recreate", action="store_true")
    p.set_defaults(func=cmd_ingest)

    # ---- pipeline ----
    p = sub.add_parser("pipeline", help="M7 全链路 / 定时守护")
    p.add_argument("--once", action="store_true", help="立即执行一次")
    p.add_argument("--date", default=None)
    p.add_argument("--steps", default=None, help="指定步骤(crawler,extractor,...,report)")
    p.add_argument("--report", action="store_true", help="全链路末尾生成日报")
    p.add_argument("--cninfo-once", action="store_true", help="cninfo watchlist 全链路(公告+调研+IRM)")
    p.set_defaults(func=cmd_pipeline)

    # ---- search ----
    p = sub.add_parser("search", help="检索知识库")
    p.add_argument("query", help="自然语言查询")
    p.add_argument("--top", type=int, default=10)
    p.add_argument("--source", default=None)
    p.add_argument("--sentiment", default=None, choices=["positive", "negative", "neutral"])
    p.add_argument("--min-importance", type=int, default=None, dest="min_importance")
    p.add_argument("--stock", default=None, help="6 位股票代码")
    p.add_argument("--industry", default=None, help="行业名")
    p.set_defaults(func=cmd_search)

    # ---- add-entry ----
    p = sub.add_parser("add-entry", help="为已有源追加 extra_homepages 入口")
    p.add_argument("source", help="已有源 id(如 wallstreetcn)")
    p.add_argument("urls", nargs="+", help="额外入口 URL(可多个)")
    p.set_defaults(func=cmd_add_entry)

    # ---- discover ----
    p = sub.add_parser("discover", help="分析站点首页,推荐 sources.yaml 配置")
    p.add_argument("url", help="站点首页 URL(主入口)")
    p.add_argument("--extra", action="append", default=[], dest="extra_urls",
                   help="额外入口 URL,可重复使用(同站多频道)")
    p.add_argument("--add", action="store_true", help="自动追加到 configs/sources.yaml")
    p.add_argument("--name", default=None, help="站点中文名(如 华尔街见闻)")
    p.set_defaults(func=cmd_discover)

    # ---- report ----
    p = sub.add_parser("report", help="生成每日 HTML 报告并上传")
    p.add_argument("--date", default=None)
    p.add_argument("--no-upload", action="store_true", help="仅生成不上传")
    p.set_defaults(func=cmd_report)

    # ---- watchlist ----
    p = sub.add_parser("watchlist", help="管理 cninfo 公告关注列表")
    sp = p.add_subparsers(dest="wl_cmd")
    pa = sp.add_parser("add", help="添加关注公司")
    pa.add_argument("code", help="6 位股票代码")
    pa.add_argument("name", help="公司简称")
    pa.add_argument("--note", default="", help="备注")
    pa.set_defaults(func=cmd_watchlist_add)
    pr = sp.add_parser("remove", help="移除关注公司")
    pr.add_argument("code", help="6 位股票代码")
    pr.set_defaults(func=cmd_watchlist_remove)
    pl = sp.add_parser("list", help="查看关注列表")
    pl.set_defaults(func=cmd_watchlist_list)

    # ---- report-import ----
    p = sub.add_parser("report-import", help="历史日报 HTML 解析入库 (M10)")
    p.add_argument("--dir", default=None, help="日报目录(默认 REPORT_HISTORY_DIR)")
    p.add_argument("--date", default=None, help="仅导入指定日期 YYYYMMDD")
    p.add_argument("--type", default=None, choices=["finance", "intl"], help="仅导入指定类型")
    p.add_argument("--force", action="store_true", help="已存在也覆盖重导")
    p.set_defaults(func=cmd_report_import)

    # ---- stock-report ----
    p = sub.add_parser("stock-report", help="生成关注列表个股日报")
    p.add_argument("--no-upload", action="store_true", help="仅生成不上传")
    p.set_defaults(func=cmd_stock_report)

    # ---- cninfo ----
    p = sub.add_parser("cninfo", help="cninfo watchlist 抓取(公告+调研+互动易, API 优先)")
    p.add_argument("--no-save", action="store_true")
    p.add_argument("--enrich-pdf", action="store_true", help="下载 PDF 补充正文")
    p.add_argument("--pdf-limit", type=int, default=50, help="PDF 最多处理 N 篇")
    p.set_defaults(func=cmd_cninfo)

    # ---- status ----
    p = sub.add_parser("status", help="数据总览")
    p.set_defaults(func=cmd_status)

    args = parser.parse_args()
    if args.command is None:
        parser.print_help()
        return 0

    # 记录操作日志
    cmd_name = args.command
    # 提取关键参数作为日志详情
    detail_parts = []
    for attr in ["source", "date", "query", "days", "stock", "industry",
                 "sentiment", "once", "cninfo_once", "report"]:
        val = getattr(args, attr, None)
        if val is not None and val is not False and val != "" and val != 0:
            detail_parts.append(f"{attr}={val}")
    detail = " ".join(detail_parts) if detail_parts else ""

    from time import perf_counter
    _log_operation_start(cmd_name, detail)
    started = perf_counter()
    rc = args.func(args)
    elapsed = perf_counter() - started
    _log_operation_end(cmd_name, rc == 0, elapsed)
    return rc


if __name__ == "__main__":
    raise SystemExit(main())
