#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""保险小老板 4-Skill 云端「一键全覆盖」集成测试。

把你看到的整个文件保存到云端任意路径（例如 /tmp/cloud_insurance_full_test.py），然后执行::

    python3 /tmp/cloud_insurance_full_test.py

或在仓库内::

    python3 /app/skills/insurance-customer-policy/src/scripts/cloud_insurance_full_test.py

特性
----
- **自动探测** skills 根目录：``INSURANCE_SKILLS_ROOT`` / ``/app/skills`` / 当前目录下的 ``skills`` 等。
- **优先使用** 各 skill 的 ``src/scripts/cli.py`` wrapper；若旧镜像没有 wrapper，则自动回退到
  ``PYTHONPATH=<pkg> python -m insurance_*_cli``。
- **隔离数据库**：默认把所有 SQLite 写到 ``/tmp/insurance_cloud_test_<pid>/``，不污染业务数据
  （也可用环境变量 ``INSURANCE_CLOUD_TEST_DIR`` 指定目录）。
- **覆盖** insurance-customer-policy / insurance-product-analysis /
  insurance-schedule-renewal / insurance-sales-pipeline 的**全部子命令**，
  以及 ``dashboard_all.py`` 的 JSON 聚合；可选 ``--smoke`` 只跑快速冒烟。
- ``-v`` / ``--verbose``：逐步打印每条子进程命令、退出码与 stdout（默认截断）；
  ``--verbose-json`` 与 ``-v`` 联用时不截断 JSON（输出可能很长）。

环境变量（均可选）
-----------------
INSURANCE_SKILLS_ROOT   直接指定 skills 目录（包含四个 insurance-* 子目录）
INSURANCE_CLOUD_TEST_DIR  测试用的 SQLite 父目录
INSURANCE_CLOUD_TEST_SMOKE=1  等价于 ``--smoke``
"""
from __future__ import annotations

import argparse
import json
import os
import shlex
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any

# 由 main() 设置：详细日志走 stderr，避免与 stdout JSON 混淆
VERBOSE = False
VERBOSE_FULL_JSON = False
VERBOSE_JSON_MAX = 4000


def _log_invocation(title: str, cmd: list[str], r: subprocess.CompletedProcess[str]) -> None:
    if not VERBOSE:
        return
    print(f"\n[VERBOSE] === {title} ===", file=sys.stderr)
    print(f"  $ {' '.join(shlex.quote(str(x)) for x in cmd)}", file=sys.stderr)
    print(f"  exit_code={r.returncode}", file=sys.stderr)
    out = (r.stdout or "").strip()
    if out:
        if VERBOSE_FULL_JSON or len(out) <= VERBOSE_JSON_MAX:
            print(f"  stdout:\n{out}", file=sys.stderr)
        else:
            print(f"  stdout (truncated {VERBOSE_JSON_MAX} chars):\n{out[:VERBOSE_JSON_MAX]}…", file=sys.stderr)
    err = (r.stderr or "").strip()
    if err:
        print(f"  stderr:\n{err[:4000]}", file=sys.stderr)


# ---------------------------------------------------------------------------
# 路径探测
# ---------------------------------------------------------------------------

SKILL_FOLDERS = {
    "customer": "insurance-customer-policy",
    "product": "insurance-product-analysis",
    "schedule": "insurance-schedule-renewal",
    "pipeline": "insurance-sales-pipeline",
}

MODULES = {
    "customer": "insurance_customer_cli",
    "product": "insurance_product_cli",
    "schedule": "insurance_schedule_cli",
    "pipeline": "insurance_pipeline_cli",
}


def _has_customer_skill(root: Path) -> bool:
    d = root / SKILL_FOLDERS["customer"]
    return d.is_dir() and (
        (d / "src" / "src" / "insurance_customer_cli" / "__init__.py").is_file()
        or (d / "src" / "scripts" / "insurance_customer_cli" / "__init__.py").is_file()
        or (d / "scripts" / "insurance_customer_cli" / "__init__.py").is_file()
        or (d / "insurance_customer_cli" / "__init__.py").is_file()
    )


def discover_skills_root() -> Path:
    env = os.environ.get("INSURANCE_SKILLS_ROOT") or os.environ.get("SKILLS_ROOT")
    if env:
        p = Path(env).expanduser().resolve()
        if _has_customer_skill(p):
            return p
        raise SystemExit(
            f"[FAIL] INSURANCE_SKILLS_ROOT={p} 下未找到完整的 insurance-customer-policy 包。"
        )

    script_here = Path(__file__).resolve().parent
    candidates = [
        script_here.parent.parent.parent,  # .../skills（脚本在 repo 标准位置时）
        Path("/app/skills"),
        Path.cwd() / "skills",
        Path.cwd(),
        Path.home() / "sophclaw-skills" / "skills",
    ]
    for c in candidates:
        try:
            if c and _has_customer_skill(c.resolve()):
                return c.resolve()
        except OSError:
            continue

    raise SystemExit(
        "[FAIL] 无法自动定位 skills 根目录。\n"
        "请确认云端已部署包含四个 insurance-* skill 的目录树，然后设置：\n"
        "  export INSURANCE_SKILLS_ROOT=/实际路径/skills\n"
        "常见布局示例：/app/skills/insurance-customer-policy/src/src/insurance_customer_cli/...\n"
        "若文件不存在，说明镜像未更新：请在 CI/CD 或挂载卷中包含 feat/insurance-4-skills 分支代码。"
    )


def resolve_pkg_parent(skills_root: Path, key: str) -> Path | None:
    folder = SKILL_FOLDERS[key]
    pkg = MODULES[key]
    d = skills_root / folder
    candidates = [
        d / "src" / "scripts",
        d / "src" / "src",
        d / "src",
        d / "scripts",
        Path("/app/skills") / folder / "src" / "scripts",
        Path("/app/skills") / folder / "src" / "src",
        Path("/app/skills") / folder / "src",
        Path("/app/skills") / folder / "scripts",
        d,
    ]
    for c in candidates:
        try:
            if (c / pkg / "__init__.py").is_file():
                return c
        except OSError:
            continue
    return None


def resolve_cli_wrapper(skills_root: Path, key: str) -> Path | None:
    folder = SKILL_FOLDERS[key]
    d = skills_root / folder
    candidates = [
        d / "src" / "scripts" / "cli.py",
        d / "scripts" / "cli.py",
        Path("/app/skills") / folder / "src" / "scripts" / "cli.py",
    ]
    for c in candidates:
        try:
            if c.is_file():
                return c
        except OSError:
            continue
    return None


def resolve_dashboard_all(skills_root: Path) -> Path | None:
    folder = SKILL_FOLDERS["customer"]
    d = skills_root / folder
    candidates = [
        d / "src" / "scripts" / "dashboard_all.py",
        d / "scripts" / "dashboard_all.py",
        Path("/app/skills") / folder / "src" / "scripts" / "dashboard_all.py",
        Path("/app/skills") / folder / "scripts" / "dashboard_all.py",
    ]
    for c in candidates:
        try:
            if c.is_file():
                return c
        except OSError:
            continue
    return None


@dataclass
class Layout:
    skills_root: Path
    customer_pkg: Path
    product_pkg: Path
    schedule_pkg: Path
    pipeline_pkg: Path
    customer_cli: Path | None
    product_cli: Path | None
    schedule_cli: Path | None
    pipeline_cli: Path | None
    dashboard_all: Path | None


def resolve_layout(skills_root: Path) -> Layout:
    pkgs = {k: resolve_pkg_parent(skills_root, k) for k in SKILL_FOLDERS}
    missing = [k for k, p in pkgs.items() if p is None]
    if missing:
        raise SystemExit(
            f"[FAIL] 下列 skill 的包目录解析失败（缺少 insurance_*_cli 包）: {missing}\n"
            f"skills_root={skills_root}"
        )
    return Layout(
        skills_root=skills_root,
        customer_pkg=pkgs["customer"],  # type: ignore[arg-type]
        product_pkg=pkgs["product"],  # type: ignore[arg-type]
        schedule_pkg=pkgs["schedule"],  # type: ignore[arg-type]
        pipeline_pkg=pkgs["pipeline"],  # type: ignore[arg-type]
        customer_cli=resolve_cli_wrapper(skills_root, "customer"),
        product_cli=resolve_cli_wrapper(skills_root, "product"),
        schedule_cli=resolve_cli_wrapper(skills_root, "schedule"),
        pipeline_cli=resolve_cli_wrapper(skills_root, "pipeline"),
        dashboard_all=resolve_dashboard_all(skills_root),
    )


# ---------------------------------------------------------------------------
# 子进程调用
# ---------------------------------------------------------------------------

def _base_env(db_dir: Path) -> dict[str, str]:
    env = dict(os.environ)
    env["PYTHONIOENCODING"] = "utf-8"
    # 去掉 PYTHONPATH，强制走 wrapper 或我们显式注入的路径
    env.pop("PYTHONPATH", None)
    # 共享单库模式：四个 skill 共用一个 sqlite 文件。
    shared = str(db_dir / "insurance.sqlite3")
    env["INSURANCE_DB_PATH"] = shared
    # 兼容旧逻辑：显式给各 skill 变量同一个值，避免老代码路径分叉。
    env["INSURANCE_CUSTOMER_DB_PATH"] = shared
    env["INSURANCE_PRODUCT_DB_PATH"] = shared
    env["INSURANCE_SCHEDULE_DB_PATH"] = shared
    env["INSURANCE_PIPELINE_DB_PATH"] = shared
    return env


def invoke_skill(
    layout: Layout,
    key: str,
    argv: list[str],
    env: dict[str, str],
    cwd: Path | None,
) -> subprocess.CompletedProcess[str]:
    pkg = {
        "customer": layout.customer_pkg,
        "product": layout.product_pkg,
        "schedule": layout.schedule_pkg,
        "pipeline": layout.pipeline_pkg,
    }[key]
    cli = {
        "customer": layout.customer_cli,
        "product": layout.product_cli,
        "schedule": layout.schedule_cli,
        "pipeline": layout.pipeline_cli,
    }[key]
    mod = MODULES[key]

    if cli is not None and cli.is_file():
        cmd = [sys.executable, str(cli), *argv]
        r = subprocess.run(
            cmd,
            cwd=str(cwd) if cwd else None,
            env=env,
            capture_output=True,
            text=True,
            encoding="utf-8",
            timeout=120,
        )
        preview = " ".join(shlex.quote(x) for x in argv)
        if len(preview) > 280:
            preview = preview[:280] + "…"
        _log_invocation(f"{key} ({preview})", cmd, r)
        return r

    e2 = {**env, "PYTHONPATH": str(pkg)}
    cmd = [sys.executable, "-m", mod, *argv]
    r = subprocess.run(
        cmd,
        cwd=str(cwd) if cwd else None,
        env=e2,
        capture_output=True,
        text=True,
        encoding="utf-8",
        timeout=120,
    )
    preview = " ".join(shlex.quote(x) for x in argv)
    if len(preview) > 280:
        preview = preview[:280] + "…"
    _log_invocation(f"{key} via -m {mod} ({preview})", cmd, r)
    return r


def parse_json_stdout(rc: int, stdout: str, stderr: str, label: str) -> dict[str, Any]:
    out = (stdout or "").strip()
    if not out:
        raise AssertionError(f"{label}: 无 stdout，rc={rc}\nSTDERR:\n{stderr}")
    try:
        return json.loads(out)
    except json.JSONDecodeError as e:
        raise AssertionError(f"{label}: JSON 解析失败 {e}\nSTDOUT:\n{out[:2000]}\nSTDERR:\n{stderr}") from e


def must_ok(d: dict[str, Any], label: str) -> None:
    if not d.get("ok"):
        raise AssertionError(f"{label}: 期望 ok=true，实际={json.dumps(d, ensure_ascii=False)[:1200]}")


def run_customer(layout: Layout, env: dict[str, str], cwd: Path, args: list[str]) -> dict[str, Any]:
    r = invoke_skill(layout, "customer", args, env, cwd)
    if r.returncode not in (0, 1):
        raise AssertionError(
            f"customer {' '.join(args)} rc={r.returncode}\nSTDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}"
        )
    return parse_json_stdout(r.returncode, r.stdout, r.stderr, f"customer {' '.join(args)}")


def run_product(layout: Layout, env: dict[str, str], cwd: Path, args: list[str]) -> dict[str, Any]:
    r = invoke_skill(layout, "product", args, env, cwd)
    if r.returncode not in (0, 1):
        raise AssertionError(
            f"product {' '.join(args)} rc={r.returncode}\nSTDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}"
        )
    return parse_json_stdout(r.returncode, r.stdout, r.stderr, f"product {' '.join(args)}")


def run_schedule(layout: Layout, env: dict[str, str], cwd: Path, args: list[str]) -> dict[str, Any]:
    r = invoke_skill(layout, "schedule", args, env, cwd)
    if r.returncode not in (0, 1):
        raise AssertionError(
            f"schedule {' '.join(args)} rc={r.returncode}\nSTDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}"
        )
    return parse_json_stdout(r.returncode, r.stdout, r.stderr, f"schedule {' '.join(args)}")


def run_pipeline(layout: Layout, env: dict[str, str], cwd: Path, args: list[str]) -> dict[str, Any]:
    r = invoke_skill(layout, "pipeline", args, env, cwd)
    if r.returncode not in (0, 1):
        raise AssertionError(
            f"pipeline {' '.join(args)} rc={r.returncode}\nSTDOUT:\n{r.stdout}\nSTDERR:\n{r.stderr}"
        )
    return parse_json_stdout(r.returncode, r.stdout, r.stderr, f"pipeline {' '.join(args)}")


# ---------------------------------------------------------------------------
# 测试主体
# ---------------------------------------------------------------------------

def test_full(layout: Layout, db_dir: Path, smoke: bool) -> None:
    cwd = Path(tempfile.gettempdir()) / "insurance_cli_cwd_probe"
    cwd.mkdir(parents=True, exist_ok=True)

    env = _base_env(db_dir)

    ts = time.strftime("%Y%m%d%H%M%S", time.localtime())
    cid_main = f"C_MAIN_{ts}"
    cid_aux = f"C_AUX_{ts}"
    cid_del = f"C_DEL_{ts}"

    print(f"\n=== [1/5] insurance-customer-policy ===")

    d = run_customer(layout, env, cwd, [
        "customer-add", "--customer-id", cid_main, "--name", "云端主客户", "--phone", "13900000001",
        "--age", "38", "--gender", "女", "--income", "500000",
        "--household", '{"spouse":true,"children":1}',
        "--occupation", "工程师", "--birthday", "1988-05-09",
        "--tags", '["高净值"]', "--fields", "{}",
    ])
    must_ok(d, "customer-add main")

    d = run_customer(layout, env, cwd, [
        "customer-add", "--customer-id", cid_aux, "--name", "云端辅客户", "--phone", "13900000002",
        "--age", "30", "--income", "120000", "--birthday", "1996-03-15",
    ])
    must_ok(d, "customer-add aux")

    d = run_customer(layout, env, cwd, [
        "customer-add", "--customer-id", cid_del, "--name", "待删除客户", "--phone", "13900000003",
    ])
    must_ok(d, "customer-add del")

    d = run_customer(layout, env, cwd, ["customer-query", "--keyword", "云端"])
    must_ok(d, "customer-query keyword")
    assert d.get("count", 0) >= 2, d

    d = run_customer(layout, env, cwd, ["customer-query", "--customer-id", cid_main])
    must_ok(d, "customer-query id")
    assert d["customers"][0]["name"] == "云端主客户", d

    d = run_customer(layout, env, cwd, [
        "customer-update", "--customer-id", cid_main,
        "--set", '{"occupation":"架构师","income":520000}',
    ])
    must_ok(d, "customer-update")

    d = run_customer(layout, env, cwd, [
        "policy-add", "--customer-id", cid_main, "--company", "平安健康", "--product", "百万医疗2024",
        "--type", "医疗险", "--coverage", "4000000", "--premium", "800",
        "--effective-date", "2026-01-01", "--pay-period", "1年",
        "--insured", "本人", "--beneficiary", "法定",
    ])
    must_ok(d, "policy-add")
    pid = d.get("id") or d.get("policy_id")
    assert pid, d

    if not smoke:
        d = run_customer(layout, env, cwd, [
            "policy-add", "--customer-id", cid_main, "--company", "国寿", "--product", "康宁重疾",
            "--type", "重疾险", "--coverage", "300000", "--premium", "9000",
            "--effective-date", "2025-06-01", "--expire-date", "2045-06-01",
            "--next-pay-date", "2026-06-01",
        ])
        must_ok(d, "policy-add explicit dates")

    d = run_customer(layout, env, cwd, ["policy-query", "--customer-id", cid_main])
    must_ok(d, "policy-query")
    assert d.get("count", 0) >= 1, d

    d = run_customer(layout, env, cwd, ["policy-query", "--policy-id", pid])
    must_ok(d, "policy-query by policy-id")

    d = run_customer(layout, env, cwd, ["policy-query", "--product-type", "医疗险"])
    must_ok(d, "policy-query by type")

    d = run_customer(layout, env, cwd, ["policy-count-by-customer", "--customer-id", cid_main])
    must_ok(d, "policy-count-by-customer")

    d = run_customer(layout, env, cwd, [
        "policy-update", "--policy-id", pid, "--set", '{"notes":"测试备注更新"}',
    ])
    must_ok(d, "policy-update")

    d = run_customer(layout, env, cwd, [
        "followup-add", "--customer-id", cid_main, "--content", "介绍了重疾险理念",
        "--next-step", "下周递送计划书", "--next-date", "2026-05-20", "--channel", "微信",
    ])
    must_ok(d, "followup-add")

    d = run_customer(layout, env, cwd, ["followup-query", "--customer-id", cid_main])
    must_ok(d, "followup-query by customer")

    d = run_customer(layout, env, cwd, ["followup-query", "--days", "365"])
    must_ok(d, "followup-query by days")

    d = run_customer(layout, env, cwd, ["gap-analysis", "--customer-id", cid_main])
    must_ok(d, "gap-analysis single")

    d = run_customer(layout, env, cwd, ["gap-analysis", "--all"])
    must_ok(d, "gap-analysis all")

    d = run_customer(layout, env, cwd, ["gap-analysis", "--all", "--gap-type", "养老险"])
    must_ok(d, "gap-analysis gap-type")

    d = run_customer(layout, env, cwd, ["customer-segment"])
    must_ok(d, "customer-segment")

    d = run_customer(layout, env, cwd, ["customer-field-add", "--name", "risk_level", "--type", "text"])
    must_ok(d, "customer-field-add")

    d = run_customer(layout, env, cwd, ["customer-field-query"])
    must_ok(d, "customer-field-query")

    d = run_customer(layout, env, cwd, ["customer-exists", "--customer-id", cid_main, "--json"])
    must_ok(d, "customer-exists")
    assert d.get("exists") is True, d

    d = run_customer(layout, env, cwd, ["dashboard", "--json"])
    must_ok(d, "customer dashboard")

    # ------------------------------------------------------------------

    print(f"\n=== [2/5] insurance-product-analysis ===")

    pname_a = f"云测重疾A_{ts}"
    pname_b = f"云测重疾B_{ts}"

    d = run_product(layout, env, cwd, [
        "product-add", "--name", pname_a, "--company", "保司A", "--type", "重疾险",
        "--coverage-range", "10万-50万", "--premium-range", "3000-8000",
        "--age-range", "0-55岁", "--highlights", "多次赔付|癌症二次",
    ])
    must_ok(d, "product-add A")

    d = run_product(layout, env, cwd, [
        "product-add", "--name", pname_b, "--company", "保司B", "--type", "重疾险",
        "--coverage-range", "10万-50万", "--premium-range", "2500-9000",
        "--age-range", "0-60岁", "--highlights", "消费型|价格便宜",
    ])
    must_ok(d, "product-add B")

    d = run_product(layout, env, cwd, ["product-query", "--name", pname_a])
    must_ok(d, "product-query name")

    d = run_product(layout, env, cwd, ["product-query", "--type", "重疾险"])
    must_ok(d, "product-query type")

    d = run_product(layout, env, cwd, ["product-update", "--name", pname_a, "--set", '{"waiting_period":"90天"}'])
    must_ok(d, "product-update")

    # product-analyze：冷启动应返回 needs_llm
    d = run_product(layout, env, cwd, ["product-analyze", "--product", pname_a])
    assert d.get("error") == "needs_llm", d

    sample_result = json.dumps(
        {
            "key_terms": ["保100种重疾"],
            "selling_points": ["保费低"],
            "fit_audience": ["30-45岁家庭支柱"],
            "pitch_script": "这款重疾主打性价比……",
        },
        ensure_ascii=False,
    )
    d = run_product(layout, env, cwd, ["analysis-write", "--product", pname_a, "--result", sample_result])
    must_ok(d, "analysis-write")

    d = run_product(layout, env, cwd, ["product-analyze", "--product", pname_a])
    must_ok(d, "product-analyze cached")

    d = run_product(layout, env, cwd, ["gap-analysis", "--customer-id", cid_main])
    must_ok(d, "product gap-analysis forward")

    d = run_product(layout, env, cwd, ["customer-match", "--product", pname_a])
    must_ok(d, "customer-match by product")

    d = run_product(layout, env, cwd, ["customer-match", "--customer-id", cid_aux])
    must_ok(d, "customer-match by customer")

    d = run_product(layout, env, cwd, ["product-compare", "--product-a", pname_a, "--product-b", pname_b])
    must_ok(d, "product-compare")

    d = run_product(layout, env, cwd, ["dashboard", "--json"])
    must_ok(d, "product dashboard")

    # ------------------------------------------------------------------

    print(f"\n=== [3/5] insurance-schedule-renewal ===")

    d = run_schedule(layout, env, cwd, ["renewal-query", "--days", "3650"])
    must_ok(d, "renewal-query")

    d = run_schedule(layout, env, cwd, ["renewal-remind", "--days", "3650"])
    must_ok(d, "renewal-remind")

    d = run_schedule(layout, env, cwd, ["renewal-remind", "--days", "3650", "--mark-sent"])
    must_ok(d, "renewal-remind mark-sent")

    d = run_schedule(layout, env, cwd, ["followup-check", "--days", "30"])
    must_ok(d, "followup-check")

    d = run_schedule(layout, env, cwd, ["followup-remind", "--days", "30"])
    must_ok(d, "followup-remind")

    d = run_schedule(layout, env, cwd, ["followup-remind", "--days", "30", "--mark-sent"])
    must_ok(d, "followup-remind mark-sent")

    d = run_schedule(layout, env, cwd, ["dormant-query", "--days", "60"])
    must_ok(d, "dormant-query")

    d = run_schedule(layout, env, cwd, ["dormant-wake", "--days", "60"])
    must_ok(d, "dormant-wake")

    d = run_schedule(layout, env, cwd, ["special-date-query", "--type", "birthday", "--month", "5"])
    must_ok(d, "special-date-query birthday")

    d = run_schedule(layout, env, cwd, ["special-date-query", "--type", "anniversary", "--month", "6"])
    must_ok(d, "special-date-query anniversary")

    d = run_schedule(layout, env, cwd, ["daily-summary"])
    must_ok(d, "daily-summary")

    d = run_schedule(layout, env, cwd, ["dashboard", "--json"])
    must_ok(d, "schedule dashboard")

    # ------------------------------------------------------------------

    print(f"\n=== [4/5] insurance-sales-pipeline ===")

    d = run_pipeline(layout, env, cwd, [
        "deal-add", "--customer-id", cid_main, "--product", pname_a, "--product-type", "重疾险",
        "--stage", "在谈", "--estimated-premium", "8000", "--expected-close-date", "2026-05-30",
        "--note", "初次接触",
    ])
    must_ok(d, "deal-add")
    deal_win = d["id"]

    d = run_pipeline(layout, env, cwd, [
        "deal-add", "--customer-id", cid_aux, "--product", pname_b, "--product-type", "重疾险",
        "--stage", "在谈", "--estimated-premium", "6000",
    ])
    must_ok(d, "deal-add aux")
    deal_lose = d["id"]

    d = run_pipeline(layout, env, cwd, ["deal-query", "--deal-id", deal_win])
    must_ok(d, "deal-query id")

    d = run_pipeline(layout, env, cwd, ["deal-query", "--customer-id", cid_main])
    must_ok(d, "deal-query customer")

    d = run_pipeline(layout, env, cwd, ["deal-query", "--stage", "在谈"])
    must_ok(d, "deal-query stage")

    d = run_pipeline(layout, env, cwd, ["deal-query", "--status", "active"])
    must_ok(d, "deal-query active")

    d = run_pipeline(layout, env, cwd, [
        "deal-update", "--deal-id", deal_win, "--set", '{"note":"更新备注"}',
    ])
    must_ok(d, "deal-update")

    d = run_pipeline(layout, env, cwd, [
        "deal-stage", "--deal-id", deal_win, "--to", "方案中", "--note", "已递方案",
    ])
    must_ok(d, "deal-stage ->方案中")

    d = run_pipeline(layout, env, cwd, [
        "deal-stage", "--deal-id", deal_win, "--to", "议价", "--note", "讨论折扣",
    ])
    must_ok(d, "deal-stage ->议价")

    d = run_pipeline(layout, env, cwd, [
        "deal-stage", "--deal-id", deal_win, "--to", "已成交",
        "--actual-premium", "8800", "--note", "签单成功",
    ])
    must_ok(d, "deal-stage ->已成交")

    month = time.strftime("%Y-%m", time.localtime())
    d = run_pipeline(layout, env, cwd, ["deal-forecast", "--month", month])
    must_ok(d, "deal-forecast")

    d = run_pipeline(layout, env, cwd, ["target-track", "--month", month, "--target", "100000"])
    must_ok(d, "target-track write")

    d = run_pipeline(layout, env, cwd, ["target-track", "--month", month])
    must_ok(d, "target-track read")

    d = run_pipeline(layout, env, cwd, [
        "deal-lost", "--deal-id", deal_lose, "--reason", "竞品", "--note", "选了别家",
    ])
    must_ok(d, "deal-lost")

    d = run_pipeline(layout, env, cwd, ["deal-lost", "--stats", "--month", month])
    must_ok(d, "deal-lost stats")

    d = run_pipeline(layout, env, cwd, ["dashboard", "--json"])
    must_ok(d, "pipeline dashboard")

    # ------------------------------------------------------------------

    print(f"\n=== [5/5] dashboard_all + 清理 ===")

    if layout.dashboard_all is None or not layout.dashboard_all.is_file():
        print("[WARN] 未找到 dashboard_all.py，跳过聚合测试（请更新镜像）。")
    else:
        _dacmd = [sys.executable, str(layout.dashboard_all)]
        r = subprocess.run(
            _dacmd,
            cwd=str(cwd),
            env=env,
            capture_output=True,
            text=True,
            encoding="utf-8",
            timeout=120,
        )
        _log_invocation("dashboard_all (JSON aggregate)", _dacmd, r)
        assert r.returncode == 0, f"dashboard_all rc={r.returncode}\n{r.stderr}\n{r.stdout[:800]}"
        agg = json.loads(r.stdout)
        must_ok(agg, "dashboard_all")
        assert "overview" in agg and "skills" in agg, agg
        # 兼容两种聚合结构：
        # 1) 旧版：skills 为各子 skill dashboard 结果（每项含 ok）
        # 2) 新版共享库：skills={"source":"shared-db"}
        skills_obj = agg.get("skills") or {}
        if isinstance(skills_obj, dict):
            has_subskill_payload = any(isinstance(v, dict) for v in skills_obj.values())
            if has_subskill_payload:
                for sk, sub in skills_obj.items():
                    if isinstance(sub, dict):
                        assert sub.get("ok"), f"sub skill {sk} failed: {sub}"

        _mdcmd = [sys.executable, str(layout.dashboard_all), "--md"]
        r = subprocess.run(
            _mdcmd,
            cwd=str(cwd),
            env=env,
            capture_output=True,
            text=True,
            encoding="utf-8",
            timeout=120,
        )
        _log_invocation("dashboard_all --md", _mdcmd, r)
        assert r.returncode == 0, r.stderr
        assert "经营概览" in r.stdout, r.stdout[:500]

    # 清理：删产品、删保单、删客户
    d = run_product(layout, env, cwd, ["product-delete", "--name", pname_a, "--yes"])
    must_ok(d, "product-delete A")

    d = run_product(layout, env, cwd, ["product-delete", "--name", pname_b, "--yes"])
    must_ok(d, "product-delete B")

    # 查询主客户所有保单并软删
    pq = run_customer(layout, env, cwd, ["policy-query", "--customer-id", cid_main])
    must_ok(pq, "policy-query for cleanup")
    for pol in pq.get("policies", []):
        pid_c = pol.get("id") or pol.get("policy_id")
        if pid_c:
            run_customer(layout, env, cwd, ["policy-delete", "--policy-id", pid_c, "--yes"])

    run_pipeline(layout, env, cwd, ["deal-delete", "--deal-id", deal_win, "--yes"])
    # deal_lose 已是流失状态，也可删除
    dq = run_pipeline(layout, env, cwd, ["deal-query", "--customer-id", cid_aux])
    if dq.get("deals"):
        run_pipeline(layout, env, cwd, ["deal-delete", "--deal-id", dq["deals"][0]["id"], "--yes"])

    run_customer(layout, env, cwd, ["customer-delete", "--customer-id", cid_main, "--yes"])
    run_customer(layout, env, cwd, ["customer-delete", "--customer-id", cid_aux, "--yes"])
    run_customer(layout, env, cwd, ["customer-delete", "--customer-id", cid_del, "--yes"])

    print("\n>>> 全部测试通过 <<<")
    print(
        "说明：若未加 -v，上述仅显示阶段标题；断言全部通过即视为成功。"
        "验证退出码请执行:  echo $?   （应为 0）",
        file=sys.stderr,
    )


def main() -> int:
    global VERBOSE, VERBOSE_FULL_JSON
    parser = argparse.ArgumentParser(description="保险 4-skill 云端全覆盖测试")
    parser.add_argument("--smoke", action="store_true", help="跳过部分冗长用例")
    parser.add_argument("-v", "--verbose", action="store_true", help=" stderr 打印每条 CLI 调用与输出摘要")
    parser.add_argument(
        "--verbose-json",
        action="store_true",
        help="与 -v 合用：stdout 不截断（JSON 可能很长）",
    )
    args = parser.parse_args()
    smoke = args.smoke or os.environ.get("INSURANCE_CLOUD_TEST_SMOKE") == "1"
    VERBOSE = bool(args.verbose)
    VERBOSE_FULL_JSON = bool(args.verbose_json)

    try:
        sys.stdout.reconfigure(encoding="utf-8")
        sys.stderr.reconfigure(encoding="utf-8")
    except (AttributeError, OSError):
        pass

    skills_root = discover_skills_root()
    layout = resolve_layout(skills_root)

    db_base = os.environ.get("INSURANCE_CLOUD_TEST_DIR")
    if db_base:
        db_dir = Path(db_base).expanduser().resolve()
        db_dir.mkdir(parents=True, exist_ok=True)
    else:
        db_dir = Path(tempfile.mkdtemp(prefix=f"insurance_cloud_test_{os.getpid()}_"))

    print("------ 保险 Skill 云端全覆盖自检 ------")
    print(f"skills_root     = {layout.skills_root}")
    print(f"test_db_dir     = {db_dir}")
    print(f"customer_cli    = {layout.customer_cli or '(fallback: python -m)'}")
    print(f"dashboard_all   = {layout.dashboard_all or '(missing)'}")
    print(f"smoke_mode      = {smoke}")
    print(f"verbose         = {VERBOSE}" + (" +full_json" if VERBOSE_FULL_JSON else ""))

    test_full(layout, db_dir, smoke=smoke)
    print("exit_code=0", file=sys.stderr)
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except AssertionError as e:
        print(f"\n[ASSERT FAILED] {e}", file=sys.stderr)
        raise SystemExit(1)
