"""_006_streamlit 的测试用例

    python -X utf8 -m _006_streamlit.test_app          # 离线（默认）
    python -X utf8 -m _006_streamlit.test_app --live   # 打真实后端

测的是 api_client.py —— 前端里唯一有逻辑的部分。`app.py` 全是 st.* 调用，
import 它就等于渲染一遍页面，没法当函数测；把网络这层拆出来正是为了能测。

离线模式把 `requests.post` / `requests.get` 换成假函数，所以不需要后端在跑。
`--live` 需要 `python -X utf8 -m _005_fastapi.main` 已经起好。

和 _005_fastapi/test_api.py 同一套框架：_check 计数，失败退出码非 0。
"""

import argparse
import sys

import requests

from _006_streamlit import api_client
from _006_streamlit.api_client import (
    BackendError,
    ask_backend,
    ask_backend_stream,
    check_health,
)

passed = 0
failed = 0


def _check(condition: bool, label: str, detail: str = "") -> None:
    global passed, failed
    if condition:
        passed += 1
        print(f"  [PASS] {label}")
    else:
        failed += 1
        print(f"  [FAIL] {label}" + (f" -> {detail}" if detail else ""))


class FakeResponse:
    """够用的 requests.Response 替身：只有 status_code / json() / text"""

    def __init__(self, status_code=200, payload=None, text=None):
        self.status_code = status_code
        self._payload = payload
        # text 显式传 "" 时也要尊重，所以用 is None 判断而不是 or
        self.text = text if text is not None else str(payload)

    def json(self):
        if self._payload is None:
            raise ValueError("不是 JSON")
        return self._payload


class FakePost:
    """替掉 requests.post。返回固定响应或抛指定异常，并记录收到的参数"""

    def __init__(self, response=None, error=None):
        self.response = response
        self.error = error
        self.calls = []

    def __call__(self, url, json=None, timeout=None):
        self.calls.append({"url": url, "json": json, "timeout": timeout})
        if self.error is not None:
            raise self.error
        return self.response


def _patch_post(fake) -> None:
    """把 api_client 用的 requests.post 换掉。

    api_client 里是 `requests.post(...)`（模块属性访问），所以改 requests 模块上的
    这个名字就能生效。每个用例调用一次，最后一个用例跑完不用还原 —— 进程随即结束。
    """
    requests.post = fake


class FakeStreamResponse(FakeResponse):
    """流式响应的替身：iter_lines 逐行吐出给定内容，并支持 with。

    真的流式响应是 `with requests.post(..., stream=True) as r:` 用的，
    所以必须实现上下文管理协议，否则 api_client 那句 with 就报 AttributeError。
    closed 记下有没有被正确关闭 —— 连接泄漏在测试里看不出症状，得显式断言。
    """

    def __init__(self, lines=None, status_code=200, payload=None, text=None):
        super().__init__(status_code=status_code, payload=payload, text=text)
        self.lines = lines or []
        self.closed = False

    def __enter__(self):
        return self

    def __exit__(self, *exc_info):
        self.closed = True
        return False

    def iter_lines(self, decode_unicode=False):
        for line in self.lines:
            yield line


class FakeStreamPost:
    """替掉 requests.post 的流式版本：额外记录 stream 参数"""

    def __init__(self, response=None, error=None):
        self.response = response
        self.error = error
        self.calls = []

    def __call__(self, url, json=None, timeout=None, stream=None):
        self.calls.append(
            {"url": url, "json": json, "timeout": timeout, "stream": stream}
        )
        if self.error is not None:
            raise self.error
        return self.response


def _stream_lines(*messages) -> list:
    """把消息字典转成后端那种"一行一个 JSON"的形状"""
    import json as _json

    return [_json.dumps(m, ensure_ascii=False) for m in messages]


# ======================
# 用例 1：正常返回
# ======================
def test_ask_ok() -> None:
    print("\n[1] ask_backend 正常返回")
    answer = "人参具有大补元气、补脾益肺等功效。"
    fake = FakePost(FakeResponse(200, {"input": "人参有什么功效？", "output": answer}))
    _patch_post(fake)

    result = ask_backend("人参有什么功效？", user_id="u-1", base_url="http://127.0.0.1:8000")

    _check(result == answer, "取到 output 里的回答", repr(result))
    call = fake.calls[0]
    _check(call["url"] == "http://127.0.0.1:8000/process", "URL 拼接正确", call["url"])
    _check(
        call["json"] == {"input": "人参有什么功效？", "user_id": "u-1"},
        "请求体是 input + user_id",
        str(call["json"]),
    )
    _check(call["timeout"] == api_client.DEFAULT_TIMEOUT, "默认超时 120 秒", str(call["timeout"]))


# ======================
# 用例 2：base_url 末尾斜杠
# ======================
def test_base_url_trailing_slash() -> None:
    print("\n[2] base_url 末尾带斜杠")
    fake = FakePost(FakeResponse(200, {"output": "回答"}))
    _patch_post(fake)

    ask_backend("问题", user_id="u", base_url="http://127.0.0.1:8000/")

    # 不处理的话会拼出 //process
    _check(
        fake.calls[0]["url"] == "http://127.0.0.1:8000/process",
        "不会拼出双斜杠",
        fake.calls[0]["url"],
    )


# ======================
# 用例 3：网络异常
# ======================
def test_network_errors() -> None:
    print("\n[3] 网络异常都收敛成 BackendError")
    cases = [
        (requests.ConnectionError("拒绝连接"), "连不上", "ConnectionError"),
        (requests.Timeout("超时"), "超时", "Timeout"),
        (requests.RequestException("其他错误"), "失败", "RequestException"),
    ]
    for error, keyword, label in cases:
        _patch_post(FakePost(error=error))
        try:
            ask_backend("问题", user_id="u")
            _check(False, f"{label} 抛出 BackendError", "没有抛异常")
        except BackendError as exc:
            _check(keyword in str(exc), f"{label} -> 含'{keyword}'的中文提示", str(exc))
        except Exception as exc:
            _check(False, f"{label} 抛出 BackendError", f"抛的是 {type(exc).__name__}")

    # ConnectionError 的提示里要给出启动命令，用户照着做就行
    _patch_post(FakePost(error=requests.ConnectionError("拒绝连接")))
    try:
        ask_backend("问题", user_id="u")
    except BackendError as exc:
        _check("_005_fastapi.main" in str(exc), "连不上时提示启动命令", str(exc))


# ======================
# 用例 4：HTTP 错误码
# ======================
def test_http_errors() -> None:
    print("\n[4] 后端返回错误码")

    # 500：后端仍然给 {"input", "output"}，把它的文案透出来
    _patch_post(FakePost(FakeResponse(500, {"input": "", "output": api_client.ERROR_OUTPUT})))
    try:
        ask_backend("问题", user_id="u")
        _check(False, "500 抛出 BackendError", "没有抛异常")
    except BackendError as exc:
        _check(str(exc) == api_client.ERROR_OUTPUT, "500 透出后端的错误文案", str(exc))

    # 422：入参校验没过，把 detail 带出来，比只看到 "422" 有用
    _patch_post(FakePost(FakeResponse(422, {"detail": [{"msg": "不能为空"}]}, text='{"detail":"不能为空"}')))
    try:
        ask_backend("", user_id="u")
        _check(False, "422 抛出 BackendError", "没有抛异常")
    except BackendError as exc:
        _check("参数不合法" in str(exc), "422 提示参数不合法", str(exc))

    # 500 但响应体不是 JSON（比如反向代理返的 HTML 错误页）
    _patch_post(FakePost(FakeResponse(502, None, text="<html>Bad Gateway</html>")))
    try:
        ask_backend("问题", user_id="u")
        _check(False, "502 抛出 BackendError", "没有抛异常")
    except BackendError as exc:
        _check("502" in str(exc), "非 JSON 错误响应也有提示", str(exc))


# ======================
# 用例 5：响应体异常
# ======================
def test_bad_body() -> None:
    print("\n[5] 200 但响应体不对")
    cases = [
        (FakeResponse(200, None, text="not json"), "不是 JSON", "不是 JSON"),
        (FakeResponse(200, {"input": "x"}), "空回答", "没有 output 键"),
        (FakeResponse(200, {"output": None}), "空回答", "output 为 None"),
        (FakeResponse(200, {"output": "   "}), "空回答", "output 全空白"),
    ]
    for response, keyword, label in cases:
        _patch_post(FakePost(response))
        try:
            ask_backend("问题", user_id="u")
            _check(False, f"{label} 抛出 BackendError", "没有抛异常")
        except BackendError as exc:
            _check(keyword in str(exc), f"{label} -> 含'{keyword}'的提示", str(exc))


# ======================
# 用例 6：健康检查
# ======================
def test_check_health() -> None:
    print("\n[6] check_health")

    class FakeGet:
        def __init__(self, response=None, error=None):
            self.response = response
            self.error = error
            self.calls = []

        def __call__(self, url, timeout=None):
            self.calls.append({"url": url, "timeout": timeout})
            if self.error is not None:
                raise self.error
            return self.response

    cases = [
        (FakeGet(FakeResponse(200, {"status": "ok", "message": "正常"})), True, "后端正常 -> True"),
        (FakeGet(FakeResponse(200, {"status": "degraded"})), False, "status 不是 ok -> False"),
        (FakeGet(FakeResponse(500, {})), False, "500 -> False"),
        (FakeGet(FakeResponse(200, None, text="not json")), False, "非 JSON -> False"),
        (FakeGet(error=requests.ConnectionError("拒绝连接")), False, "连不上 -> False"),
        (FakeGet(error=requests.Timeout("超时")), False, "超时 -> False"),
    ]
    for fake, expected, label in cases:
        requests.get = fake
        _check(check_health("http://127.0.0.1:8000") is expected, label)

    # 健康检查是给状态灯用的，超时必须短，不能拿 120 秒去卡页面
    _check(fake.calls[-1]["timeout"] == 5, "健康检查超时是 5 秒", str(fake.calls[-1]["timeout"]))


# ======================
# 用例 7：流式接口正常返回
# ======================
def test_stream_ok() -> None:
    """逐条 yield thinking + final，且请求带上了 stream=True"""
    print("\n[7] ask_backend_stream 正常返回")

    messages = [
        {"type": "thinking", "node": "语义转写节点", "data": {"input_semantic_trans": "人参有什么功效？"}},
        {"type": "thinking", "node": "中医意图识别节点", "data": {"is_zhongyi_intent": True}},
        {"type": "final", "input": "人参有什么功效？", "output": "人参能大补元气。"},
    ]
    response = FakeStreamResponse(lines=_stream_lines(*messages))
    fake = FakeStreamPost(response)
    _patch_post(fake)

    got = list(
        ask_backend_stream("人参有什么功效？", user_id="u-1", base_url="http://127.0.0.1:8001")
    )

    _check(len(got) == 3, "收到 3 条消息", f"{len(got)} 条")
    _check(got == messages, "消息原样透出，没被改写")
    _check(got[-1]["type"] == "final", "最后一条是 final", got[-1]["type"])

    call = fake.calls[0]
    _check(call["url"] == "http://127.0.0.1:8001/process_stream", "URL 拼接正确", call["url"])
    # 少了 stream=True，requests 会把响应体读完才返回，流式就退化成一次性接口
    _check(call["stream"] is True, "带上了 stream=True", repr(call["stream"]))
    _check(
        call["json"] == {"input": "人参有什么功效？", "user_id": "u-1"},
        "请求体符合接口契约",
        repr(call["json"]),
    )
    _check(response.closed, "响应被正确关闭（with 退出）")


# ======================
# 用例 8：流式是真的边收边出
# ======================
def test_stream_is_lazy() -> None:
    """必须是生成器、按需产出，不能先攒完再返回 —— 否则页面还是要等满 30 秒"""
    print("\n[8] ask_backend_stream 惰性产出")

    consumed = []

    class CountingResponse(FakeStreamResponse):
        def iter_lines(self, decode_unicode=False):
            for line in self.lines:
                consumed.append(line)
                yield line

    messages = [
        {"type": "thinking", "node": "A", "data": {"x": 1}},
        {"type": "thinking", "node": "B", "data": {"y": 2}},
        {"type": "final", "input": "q", "output": "done"},
    ]
    _patch_post(FakeStreamPost(CountingResponse(lines=_stream_lines(*messages))))

    generator = ask_backend_stream("q", user_id="u")
    # 只调用还没迭代：一行都不该被读
    _check(consumed == [], "调用后未迭代时不发起读取", f"已读 {len(consumed)} 行")

    first = next(generator)
    _check(first["node"] == "A", "取第一条就能拿到第一个节点", repr(first))
    _check(len(consumed) == 1, "取一条只读了一行", f"读了 {len(consumed)} 行")

    rest = list(generator)
    _check(len(rest) == 2, "剩下两条能继续取完", f"{len(rest)} 条")


# ======================
# 用例 9：流式的异常与脏数据
# ======================
def test_stream_errors() -> None:
    """连不上/超时/422 抛 BackendError；error 消息和坏行不抛，照原样处理"""
    print("\n[9] ask_backend_stream 异常处理")

    # 没能开始流的三种情况，都该收敛成 BackendError
    cases = [
        (FakeStreamPost(error=requests.ConnectionError()), "连不上", "连不上后端"),
        (FakeStreamPost(error=requests.Timeout()), "超时", "超时"),
        (
            FakeStreamPost(FakeStreamResponse(status_code=422, payload={"detail": "x"})),
            "422",
            "参数不合法",
        ),
        (
            FakeStreamPost(
                FakeStreamResponse(status_code=500, payload={"output": api_client.ERROR_OUTPUT})
            ),
            "500",
            api_client.ERROR_OUTPUT,
        ),
    ]
    for fake, label, keyword in cases:
        _patch_post(fake)
        try:
            list(ask_backend_stream("q", user_id="u"))
            _check(False, f"{label} 抛出 BackendError", "没有抛异常")
        except BackendError as exc:
            _check(keyword in str(exc), f"{label} -> 含'{keyword}'的提示", str(exc))
        except Exception as exc:
            _check(False, f"{label} 抛出 BackendError", f"抛的是 {type(exc).__name__}")

    # 后端图跑挂了：HTTP 是 200，error 是流里正常的一行，不该抛异常
    _patch_post(
        FakeStreamPost(
            FakeStreamResponse(
                lines=_stream_lines(
                    {"type": "error", "input": "q", "output": api_client.ERROR_OUTPUT}
                )
            )
        )
    )
    got = list(ask_backend_stream("q", user_id="u"))
    _check(len(got) == 1 and got[0]["type"] == "error", "error 消息照原样 yield", repr(got))

    # 空行要跳过，坏行不能让整轮问答报废
    _patch_post(
        FakeStreamPost(
            FakeStreamResponse(
                lines=[
                    "",
                    "   ",
                    "{坏掉的 json",
                    *_stream_lines({"type": "final", "input": "q", "output": "答案"}),
                ]
            )
        )
    )
    got = list(ask_backend_stream("q", user_id="u"))
    _check(len(got) == 1, "空行和坏行被跳过", f"{len(got)} 条")
    _check(got[0]["output"] == "答案", "坏行之后的消息照样收到", repr(got[0]))

    # 一条消息都没有：不是正常结束，得报出来而不是静默返回空
    _patch_post(FakeStreamPost(FakeStreamResponse(lines=[])))
    try:
        list(ask_backend_stream("q", user_id="u"))
        _check(False, "空流抛出 BackendError", "没有抛异常")
    except BackendError as exc:
        _check("没有返回任何消息" in str(exc), "空流 -> 提示没有消息", str(exc))


# ======================
# 用例 10：app.py 能正常导入
# ======================
def test_app_importable() -> None:
    """页面代码本身跑不了断言，但至少确认它没有语法错误、导入名都对得上。

    直接 import app 会执行所有 st.* 调用（脱离 streamlit runtime 会报错），
    所以用 compile + 检查引用的名字确实存在于 api_client 里。
    """
    print("\n[10] 页面静态检查")
    import ast
    from pathlib import Path

    # app.py 和 pages/ 下的每一页都过一遍，新增页面不用改这里
    here = Path(__file__).parent
    pages = [here / "app.py", *sorted((here / "pages").glob("*.py"))]
    _check(len(pages) >= 2, f"找到 {len(pages)} 个页面文件", str([p.name for p in pages]))

    for page in pages:
        source = page.read_text(encoding="utf-8")
        try:
            tree = ast.parse(source)
            _check(True, f"{page.name} 语法正确")
        except SyntaxError as exc:
            _check(False, f"{page.name} 语法正确", str(exc))
            continue

        # 从 api_client 导入的名字必须真的存在，否则运行时才会炸
        imported = [
            alias.name
            for node in ast.walk(tree)
            if isinstance(node, ast.ImportFrom) and node.module and "api_client" in node.module
            for alias in node.names
        ]
        _check(bool(imported), f"{page.name} 确实从 api_client 导入了东西", str(imported))
        missing = [name for name in imported if not hasattr(api_client, name)]
        _check(not missing, f"{page.name} 导入的名字都存在", f"缺少 {missing}")


# ======================
# --live：打真实后端
# ======================
def run_live(base_url: str) -> None:
    """对着已启动的 _005_ 后端走一遍真实调用。

    注意这里不 _patch_post —— 用的是真的 requests。
    """
    print(f"\n=== live 模式：{base_url} ===")

    print("\n[live-1] check_health")
    if not check_health(base_url):
        _check(False, "后端可用", f"连不上 {base_url}，先跑 python -X utf8 -m _005_fastapi.main")
        return
    _check(True, "后端可用")

    for i, question in enumerate(["人参有什么功效？", "帮我写一段 Python 快排代码"], start=2):
        print(f"\n[live-{i}] ask_backend {question!r}")
        try:
            answer = ask_backend(question, user_id="streamlit-live-test", base_url=base_url)
        except BackendError as exc:
            _check(False, "拿到回答", str(exc))
            continue
        _check(bool(answer.strip()), "回答非空", repr(answer))
        _check(answer != api_client.ERROR_OUTPUT, "不是错误文案", repr(answer))
        print(f"    回答: {answer[:120]}")

    print("\n[live-stream] ask_backend_stream '四君子汤由哪些药材组成？'")
    try:
        got = list(
            ask_backend_stream(
                "四君子汤由哪些药材组成？",
                user_id="streamlit-live-test",
                base_url=base_url,
            )
        )
    except BackendError as exc:
        _check(False, "流式调用成功", str(exc))
        got = []

    if got:
        thinking = [m for m in got if m.get("type") == "thinking"]
        finals = [m for m in got if m.get("type") == "final"]
        # 查图谱的问题要走完整条链，节点数明显多于闲聊
        _check(len(thinking) >= 3, f"收到多条 thinking（{len(thinking)} 条）", str(len(thinking)))
        _check(len(finals) == 1, "有且只有一条 final", f"{len(finals)} 条")
        _check(got[-1].get("type") == "final", "final 是最后一条", str(got[-1].get("type")))
        if finals:
            answer = finals[0].get("output") or ""
            _check(bool(answer.strip()), "final 的 output 非空", repr(answer))
            _check(answer != api_client.ERROR_OUTPUT, "不是错误文案", repr(answer))
            print(f"    节点: {[m.get('node') for m in thinking]}")
            print(f"    回答: {answer[:120]}")

    print("\n[live-last] 空问题应被后端挡回")
    try:
        ask_backend("", user_id="u", base_url=base_url)
        _check(False, "空问题抛出 BackendError", "没有抛异常")
    except BackendError as exc:
        _check("参数不合法" in str(exc), "空问题 -> 参数不合法", str(exc))


OFFLINE_TESTS = [
    test_ask_ok,
    test_base_url_trailing_slash,
    test_network_errors,
    test_http_errors,
    test_bad_body,
    test_check_health,
    test_stream_ok,
    test_stream_is_lazy,
    test_stream_errors,
    test_app_importable,
]


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="_006_streamlit 前端测试")
    parser.add_argument("--live", action="store_true", help="对已启动的后端发真实请求")
    parser.add_argument(
        "--base-url", default=api_client.DEFAULT_BASE_URL, help="live 模式的后端地址"
    )
    args = parser.parse_args()

    if args.live:
        run_live(args.base_url.rstrip("/"))
    else:
        print("=== 离线模式：假造 requests，不需要后端在跑 ===")
        for test in OFFLINE_TESTS:
            test()

    print(f"\n{'=' * 70}")
    print(f"通过 {passed} 条，失败 {failed} 条")
    sys.exit(1 if failed else 0)
