"""MonkeyOcrTransport 测试(M3 设计 §4): 双端点协议、两段解析、数值防御、错误翻译。

fixtures 按 2026-07-21 真实服务取证响应**脱敏二次构造**(设计 §11):
保留结构骨架/数值/元素类型,OCR 识别文本一律替换为合成占位(零业务假设 +
P5 医疗数据不入库)。
"""

import asyncio
import io
import json
import zipfile

import httpx
import pytest

from polygateway.errors import (
    RequestRejectedError,
    ResultInvalidError,
    SourceDeadError,
    TransientError,
)
from polygateway.transports.monkey_ocr import (
    MonkeyOcrTransport,
    _classify_status,
    _parse_middle_json,
)
from polygateway.types import SourceConfig


def _source(**overrides):
    base = {
        "name": "monkey_1",
        "provider": "monkey",
        "base_url": "http://10.77.0.20:7866",
        "api_key": "none",  # MonkeyOCR 无鉴权,占位惯例(CHS .env 同款)
        "model": "monkey-ocr",
        "timeout_s": 120.0,
    }
    base.update(overrides)
    return SourceConfig(**base)


def _text_body(content="LINE-1\nLINE-2"):
    # 真实形态: {"success":true,"task_type":"text","content":...,"message":...}
    return {
        "success": True,
        "task_type": "text",
        "content": content,
        "message": "Text extraction completed successfully",
    }


def _parse_body(download_url="/static/sample_parsed_1.zip"):
    # 真实形态含 output_dir/files;解析只消费 success 与 download_url
    return {
        "success": True,
        "message": "image parsing (standard) completed successfully",
        "output_dir": "/app/tmp/x",
        "files": ["u_middle.json"],
        "download_url": download_url,
    }


def _page(para_blocks, page_size=(759.0, 540.0), page_idx=0):
    return {
        "page_idx": page_idx,
        "page_size": list(page_size),
        "para_blocks": para_blocks,
        "tables": [b for b in para_blocks if b.get("type") == "table"],
    }


def _block(type_, bbox):
    body = {"type": type_, "bbox": list(bbox), "index": 0}
    if type_ == "text":
        body["lines"] = [{"spans": [{"content": "LINE-SYNTH"}]}]
    return body


_DEFAULT_PAGES = [
    _page(
        [
            _block("table", (41, 48, 218, 282)),
            _block("image", (240, 30, 700, 500)),
            _block("text", (50, 300, 200, 340)),
        ]
    )
]


def _middle_bytes(pages=_DEFAULT_PAGES, top=None):
    payload = {"pdf_info": pages, "_parse_type": "ocr", "_version_name": "0.0.1"}
    if top is not None:
        payload = top
    return json.dumps(payload).encode()


def _zip_bytes(middle=None, member="sample_middle.json"):
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        zf.writestr(member, middle if middle is not None else _middle_bytes())
        zf.writestr("sample.md", "# synth")
    return buf.getvalue()


def _transport_for(handler):
    mock = httpx.MockTransport(handler)
    return MonkeyOcrTransport(
        client_factory=lambda src: httpx.AsyncClient(base_url=src.base_url, transport=mock)
    )


def _routes(text_resp=None, parse_resp=None, zip_resp=None, health_resp=None):
    """按路径分发的 mock handler;None 项返回 500 便于暴露误路由。"""

    def handler(request: httpx.Request) -> httpx.Response:
        path = request.url.path
        if path == "/ocr/text":
            return text_resp or httpx.Response(500)
        if path == "/parse":
            return parse_resp or httpx.Response(500)
        if path.startswith("/static/"):
            return zip_resp or httpx.Response(500)
        if path == "/health":
            return health_resp or httpx.Response(500)
        return httpx.Response(500)

    return handler


class TestRecognizeText:
    async def test_normal(self):
        t = _transport_for(_routes(text_resp=httpx.Response(200, json=_text_body())))
        r = await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
        assert r.text == "LINE-1\nLINE-2"
        assert r.raw["task_type"] == "text"

    async def test_empty_content_legal(self):
        t = _transport_for(_routes(text_resp=httpx.Response(200, json=_text_body(""))))
        r = await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
        assert r.text == ""

    async def test_content_missing_result_invalid(self):
        body = _text_body()
        del body["content"]
        t = _transport_for(_routes(text_resp=httpx.Response(200, json=body)))
        with pytest.raises(ResultInvalidError):
            await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")

    async def test_content_non_str_result_invalid(self):
        t = _transport_for(_routes(text_resp=httpx.Response(200, json=_text_body(123))))
        with pytest.raises(ResultInvalidError):
            await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")

    async def test_multipart_shape(self):
        seen = {}

        def handler(request: httpx.Request) -> httpx.Response:
            seen["content_type"] = request.headers.get("content-type", "")
            seen["body"] = request.read()
            return httpx.Response(200, json=_text_body())

        t = _transport_for(handler)
        await t.recognize_text(image=b"IMAGE-BYTES", source=_source(), call_id="c1")
        assert seen["content_type"].startswith("multipart/form-data")
        assert b"IMAGE-BYTES" in seen["body"]
        assert b'filename="image.jpg"' in seen["body"]  # CHS invokers.py:496 固定名


class TestParseLayout:
    async def test_full_chain_relative_url(self):
        t = _transport_for(
            _routes(
                parse_resp=httpx.Response(200, json=_parse_body()),
                zip_resp=httpx.Response(200, content=_zip_bytes()),
            )
        )
        r = await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")
        types = [e.type for e in r.elements]
        assert types == ["table", "image", "text"]
        assert r.elements[0].bbox == (41.0, 48.0, 218.0, 282.0)
        assert r.elements[0].page_index == 0
        assert r.page_sizes == [(759.0, 540.0)]

    async def test_absolute_download_url(self):
        url = "http://10.77.0.20:7866/static/abs.zip"
        t = _transport_for(
            _routes(
                parse_resp=httpx.Response(200, json=_parse_body(url)),
                zip_resp=httpx.Response(200, content=_zip_bytes()),
            )
        )
        r = await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")
        assert len(r.elements) == 3

    async def test_success_false_rejected_with_status_200(self):
        body = {"success": False, "message": "unsupported"}
        t = _transport_for(_routes(parse_resp=httpx.Response(200, json=body)))
        with pytest.raises(RequestRejectedError) as ei:
            await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")
        # 设计 §10.2 有意修复: 200 响应确证服务活着,熔断按"响应即健康"记成功
        assert ei.value.status_code == 200

    async def test_download_url_missing_transient(self):
        body = _parse_body()
        del body["download_url"]
        t = _transport_for(_routes(parse_resp=httpx.Response(200, json=body)))
        with pytest.raises(TransientError):
            await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")

    async def test_non_json_response_transient(self):
        t = _transport_for(_routes(parse_resp=httpx.Response(200, content=b"<html>")))
        with pytest.raises(TransientError):
            await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")

    async def test_no_table_page_legal(self):
        pages = [_page([_block("text", (1, 2, 30, 40))])]
        t = _transport_for(
            _routes(
                parse_resp=httpx.Response(200, json=_parse_body()),
                zip_resp=httpx.Response(200, content=_zip_bytes(_middle_bytes(pages))),
            )
        )
        r = await t.parse_layout(image=b"jpg", source=_source(), call_id="c1")
        assert [e.type for e in r.elements] == ["text"]  # 无 table = 合法,不抛


class TestMiddleJsonDefense:
    """数值防御纯函数(CHS invokers.py:427-479 全量下沉)。"""

    def _expect_invalid(self, zip_bytes):
        with pytest.raises(ResultInvalidError):
            _parse_middle_json(zip_bytes)

    def test_bad_zip(self):
        self._expect_invalid(b"not-a-zip")

    def test_missing_middle_member(self):
        self._expect_invalid(_zip_bytes(member="other.json"))

    def test_pdf_info_not_list(self):
        self._expect_invalid(_zip_bytes(_middle_bytes(top={"pdf_info": {}})))

    def test_middle_not_json(self):
        self._expect_invalid(_zip_bytes(b"{broken"))

    @pytest.mark.parametrize(
        "page_size", [[-1, 540], [float("nan"), 540], [True, 540], [759], "bad"]
    )
    def test_page_size_invalid(self, page_size):
        page = _page([_block("table", (1, 2, 3, 4))])
        page["page_size"] = page_size
        self._expect_invalid(_zip_bytes(_middle_bytes([page])))

    def test_bbox_order_invalid(self):
        self._expect_invalid(
            _zip_bytes(_middle_bytes([_page([_block("table", (218, 48, 41, 282))])]))
        )

    def test_bbox_int_degenerate(self):
        # float 合法但 int() 后宽度为零: 专为 CHS shim 的裁剪路径兜底
        self._expect_invalid(
            _zip_bytes(_middle_bytes([_page([_block("table", (1.2, 1.2, 1.8, 5))])]))
        )

    def test_bbox_non_finite(self):
        self._expect_invalid(
            _zip_bytes(_middle_bytes([_page([_block("table", (1, 2, float("inf"), 4))])]))
        )

    def test_type_missing(self):
        block = {"bbox": [1, 2, 30, 40], "index": 0}
        self._expect_invalid(_zip_bytes(_middle_bytes([_page([block])])))

    def test_para_blocks_absent_defaults_empty(self):
        page = _page([])
        del page["para_blocks"]
        elements, page_sizes = _parse_middle_json(_zip_bytes(_middle_bytes([page])))
        assert elements == [] and page_sizes == [(759.0, 540.0)]

    def test_page_idx_missing_falls_back_to_enumeration(self):
        page = _page([_block("text", (1, 2, 30, 40))])
        del page["page_idx"]
        elements, _ = _parse_middle_json(_zip_bytes(_middle_bytes([page])))
        assert elements[0].page_index == 0

    @pytest.mark.parametrize("bad_idx", [True, -3, "2", 1.5])
    def test_page_idx_non_int_falls_back(self, bad_idx):
        # bool/负数/非 int 一律回退枚举序(与 _finite_number 拒 bool 的防御口径一致)
        page = _page([_block("text", (1, 2, 30, 40))])
        page["page_idx"] = bad_idx
        elements, _ = _parse_middle_json(_zip_bytes(_middle_bytes([page])))
        assert elements[0].page_index == 0


class TestErrorTranslation:
    @pytest.mark.parametrize(
        ("status", "exc_type"),
        [
            (502, TransientError),
            (429, TransientError),
            (401, SourceDeadError),
            (403, SourceDeadError),
            (404, RequestRejectedError),
        ],
    )
    async def test_http_status(self, status, exc_type):
        t = _transport_for(_routes(text_resp=httpx.Response(status)))
        with pytest.raises(exc_type) as ei:
            await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
        assert ei.value.status_code == status

    @pytest.mark.parametrize(
        ("status", "exc_type"),
        [
            (502, TransientError),
            (429, TransientError),  # 与 5xx 共用分支,但仍单列: 漏分支正是 issue #10 的成因
            (401, SourceDeadError),
            (404, RequestRejectedError),
        ],
    )
    async def test_body_survives_every_branch(self, status, exc_type):
        """issue #10: OCR 侧 message 原本只有 HTTP 状态码,拒绝理由同样丢失。"""
        body = '{"detail":"unsupported image mode CMYK"}'
        t = _transport_for(_routes(text_resp=httpx.Response(status, content=body.encode())))
        with pytest.raises(exc_type) as ei:
            await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
        assert ei.value.body_text == body
        assert str(ei.value).endswith(f" | {body}")

    def test_unread_body_degrades_without_changing_class(self):
        """取不到 body 时降级空串: 绝不能让 ResponseNotRead 逃出错误四分类。

        直接测纯函数而非走 MockTransport——真实客户端对非 stream 请求总会读完
        响应,未读态只可能在将来给 OCR 加 stream 时出现,而那正是要防的场景。
        """

        class _Unread(httpx.SyncByteStream):
            def __iter__(self):
                yield b"body"

        exc = httpx.HTTPStatusError(
            "404",
            request=httpx.Request("POST", "http://ocr.example/ocr/text"),
            response=httpx.Response(404, stream=_Unread()),
        )
        err = _classify_status(exc, "monkey_1", "text")
        assert isinstance(err, RequestRejectedError)
        assert err.body_text == ""
        assert str(err) == "monkey_1 OCR text HTTP 404"

    async def test_connect_error_transient(self):
        def handler(request):
            raise httpx.ConnectError("refused", request=request)

        t = _transport_for(handler)
        with pytest.raises(TransientError) as ei:
            await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
        assert ei.value.status_code is None

    async def test_read_timeout_transient_with_cause(self):
        def handler(request):
            raise httpx.ReadTimeout("slow", request=request)

        t = _transport_for(handler)
        with pytest.raises(TransientError) as ei:
            await t.recognize_text(image=b"jpg", source=_source(), call_id="c1")
        # __cause__ 链保留,RetryMW._failure_reason 依赖它归类 timeout
        assert isinstance(ei.value.__cause__, httpx.TimeoutException)


class TestCheckHealth:
    async def test_healthy(self):
        t = _transport_for(_routes(health_resp=httpx.Response(200, json={"status": "healthy"})))
        assert await t.check_health(source=_source()) is True

    async def test_500_unhealthy(self):
        t = _transport_for(_routes(health_resp=httpx.Response(500)))
        assert await t.check_health(source=_source()) is False

    async def test_3xx_unhealthy(self):
        # 设计 §10.1 有意收紧: VT 的 resp.ok 含 3xx,库只认 2xx
        t = _transport_for(_routes(health_resp=httpx.Response(302)))
        assert await t.check_health(source=_source()) is False

    async def test_connect_failure_false(self):
        def handler(request):
            raise httpx.ConnectError("refused", request=request)

        t = _transport_for(handler)
        assert await t.check_health(source=_source()) is False

    async def test_cancellation_passes_through(self):
        def handler(request):
            raise asyncio.CancelledError()

        t = _transport_for(handler)
        with pytest.raises(asyncio.CancelledError):
            await t.check_health(source=_source())


class TestLifecycle:
    async def test_aclose_idempotent(self):
        t = _transport_for(_routes(health_resp=httpx.Response(200)))
        await t.check_health(source=_source())
        await t.aclose()
        await t.aclose()
