"""
Kiểm thử bản vá sự cố đồng bộ hoá đơn 04/10/2026 08:15 (GMT+7) - trạng thái AI_ERROR.

Nguyên nhân gốc (đã khảo sát DOM thật của Cổng Thuế):
1. Selector "đổi ảnh captcha" cũ `.anticon-reload, span:has(svg), ...` khớp nhầm nút ĐÓNG MODAL
   (`span.ant-modal-close-x` chứa <svg>) -> ảnh không bao giờ đổi, AI đọc lại đúng 1 ảnh 3 lần.
   Nút đổi ảnh thật: `<div class="Captcha__ImageWrapper..."><img alt="captcha"><button ...>`.
2. Ảnh captcha có 2 đường nhiễu (`<path fill="none" stroke=...>`) làm model đọc lệch (O/Q, X/K).
   SVG có đúng 1 `<path fill=...>` cho mỗi ký tự -> biết trước độ dài, loại nhiễu trước khi OCR.
3. `ai_error` (chưa hề gửi đăng nhập lên Cổng Thuế) bị đếm như lỗi xác thực -> có thể ngắt mạch
   (circuit breaker) oan toàn bộ lịch đồng bộ.
"""

import struct
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from app.modules.invoices.infrastructure.adapters.gdt.gdt_captcha import (
    InvoiceGdtCaptchaMixin,
    clean_captcha_svg,
    count_captcha_glyphs,
)

# SVG mô phỏng đúng cấu trúc captcha Cổng Thuế (svg-captcha): 6 ký tự = 6 path tô đặc,
# 2 path nhiễu dạng nét (fill="none"), thứ tự path KHÔNG theo trục x.
SAMPLE_SVG = (
    '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="40" viewBox="0,0,200,40">'
    '<path fill="#333" d="M160 10L170 10L170 30L160 30Z"/>'
    '<path fill="#333" d="M10 10L20 10L20 30L10 30Z"/>'
    '<path fill="#333" d="M40 10L50 10L50 30L40 30Z"/>'
    '<path d="M5 20C60 5,120 35,195 15" stroke="#666" fill="none"/>'
    '<path d="M5 30C60 15,120 5,195 25" stroke="#888" fill="none"/>'
    '<path fill="#444" d="M70 10L80 10L80 30L70 30Z"/>'
    '<path fill="#222" d="M100 10L110 10L110 30L100 30Z"/>'
    '<path fill="#222" d="M130 10L140 10L140 30L130 30Z"/>'
    "</svg>"
)


def _png_size(png: bytes) -> tuple[int, int]:
    assert png[:8] == b"\x89PNG\r\n\x1a\n"
    return struct.unpack(">II", png[16:24])


# --------------------------------------------------------------------------
# 1. Loại nhiễu SVG + suy ra độ dài captcha
# --------------------------------------------------------------------------
def test_clean_captcha_svg_strips_noise_strokes_keeps_glyphs():
    cleaned = clean_captcha_svg(SAMPLE_SVG)
    assert 'fill="none"' not in cleaned
    assert "stroke=" not in cleaned
    assert cleaned.count("<path") == 6
    assert cleaned.startswith("<svg") and cleaned.endswith("</svg>")


def test_clean_captcha_svg_handles_attribute_order_and_closing_tag():
    svg = (
        '<svg width="200" height="40">'
        "<path fill='none' stroke='#777' d='M0 0L1 1'></path>"
        '<path stroke="#333" d="M0 0L1 1" fill="none" />'
        '<path fill="#111" d="M1 1L2 2Z"/>'
        "</svg>"
    )
    cleaned = clean_captcha_svg(svg)
    assert cleaned.count("<path") == 1
    assert 'fill="#111"' in cleaned


def test_count_captcha_glyphs_from_filled_paths():
    assert count_captcha_glyphs(SAMPLE_SVG) == 6
    # Ngoài khoảng 4-6 -> không dám suy luận độ dài
    assert count_captcha_glyphs('<svg><path fill="#111" d="M0 0"/></svg>') is None
    assert count_captcha_glyphs("") is None


def test_render_svg_is_denoised_and_upscaled():
    solver = InvoiceGdtCaptchaMixin()
    png = solver._render_svg_to_png(SAMPLE_SVG)
    width, height = _png_size(png)
    # Phóng to >= 3 lần (200x40 -> >= 600x120) để model đọc nét chữ rõ hơn
    assert width >= 600 and height >= 120


def test_render_uses_cleaned_svg(monkeypatch):
    """Ảnh render từ SVG gốc phải giống hệt ảnh render từ SVG đã loại nhiễu."""
    solver = InvoiceGdtCaptchaMixin()
    assert solver._render_svg_to_png(SAMPLE_SVG) == solver._render_svg_to_png(
        clean_captcha_svg(SAMPLE_SVG)
    )


# --------------------------------------------------------------------------
# 2. Đồng thuận có ràng buộc độ dài (loại bản đọc thiếu ký tự)
# --------------------------------------------------------------------------
class _Gateway:
    def __init__(self, answers):
        self.answers = answers
        self.calls = []
        self.prompts = []

    def analyze_vision_sync(self, req, timeout=60.0):
        from app.core.ai.domain.models import AiProviderType, AiResponse

        self.calls.append(req.canonical_model_id)
        self.prompts.append(req.prompt)
        return AiResponse(
            content=self.answers[req.canonical_model_id],
            model_used=req.canonical_model_id,
            provider_used=AiProviderType.OPENROUTER,
        )


def test_consensus_rejects_reading_with_wrong_length(monkeypatch):
    from app.modules.invoices.infrastructure.adapters.gdt.gdt_captcha import CAPTCHA_READER_MODELS
    import app.core.ai.infrastructure.gateway as gw_mod

    a, b, c = CAPTCHA_READER_MODELS
    # Sự cố thật: flash-lite đọc thiếu 1 ký tự ('KXM51'); 2 model còn lại đọc đúng 6 ký tự.
    gw = _Gateway({a: "KXMB51", b: "KXM51", c: "KXMB51"})
    monkeypatch.setattr(gw_mod, "get_ai_gateway", lambda: gw)

    value, provider = InvoiceGdtCaptchaMixin()._solve_with_ai_gateway(
        "data:image/png;base64,AAAA", expected_len=6
    )
    assert value == "KXMB51"
    assert gw.calls == [a, b, c]
    # Prompt phải nói rõ số ký tự cần đọc
    assert all("6" in p for p in gw.prompts)


def test_consensus_wrong_length_agreement_is_not_accepted(monkeypatch):
    from app.modules.invoices.infrastructure.adapters.gdt.gdt_captcha import CAPTCHA_READER_MODELS
    import app.core.ai.infrastructure.gateway as gw_mod

    a, b, c = CAPTCHA_READER_MODELS
    gw = _Gateway({a: "KXM51", b: "KXM51", c: "KXMB51"})
    monkeypatch.setattr(gw_mod, "get_ai_gateway", lambda: gw)

    value, _ = InvoiceGdtCaptchaMixin()._solve_with_ai_gateway(
        "data:image/png;base64,AAAA", expected_len=6
    )
    assert value is None


# --------------------------------------------------------------------------
# 3. Đổi ảnh captcha trên modal Playwright
# --------------------------------------------------------------------------
class _FakePage:
    def wait_for_timeout(self, ms):
        pass


class _FakeImg:
    def __init__(self, state):
        self.state = state

    @property
    def first(self):
        return self

    def count(self):
        return 1

    def is_visible(self):
        return True

    def get_attribute(self, name):
        return self.state["src"]

    def click(self):
        self.state["clicks"].append("img")


class _FakeLocator:
    def __init__(self, selector, state, refresh_selectors):
        self.selector = selector
        self.state = state
        self.refresh_selectors = refresh_selectors

    @property
    def first(self):
        return self

    def count(self):
        return 1 if self.selector in self.state["present"] else 0

    def is_visible(self):
        return self.count() > 0

    def click(self):
        self.state["clicks"].append(self.selector)
        if self.selector in self.refresh_selectors:
            self.state["images"].pop(0)
            self.state["src"] = self.state["images"][0]


class _FakeModal:
    def __init__(self, state, refresh_selectors):
        self.state = state
        self.refresh_selectors = refresh_selectors

    def locator(self, selector):
        if selector.startswith("img") and "button" not in selector:
            return _FakeImg(self.state)
        return _FakeLocator(selector, self.state, self.refresh_selectors)


def _b64_svg_src(text: str) -> str:
    import base64

    svg = SAMPLE_SVG.replace("</svg>", f"<!--{text}--></svg>")
    return "data:image/svg+xml;base64," + base64.b64encode(svg.encode()).decode()


REAL_REFRESH = "img[alt='captcha'] + button"


def _make_modal(images, refresh_works=True):
    state = {
        "src": images[0],
        "images": list(images),
        "clicks": [],
        # Nút đóng modal luôn tồn tại và có <svg> -> không bao giờ được bấm
        "present": {REAL_REFRESH, ".ant-modal-close-x", "span:has(svg)"},
    }
    modal = _FakeModal(state, {REAL_REFRESH} if refresh_works else set())
    return modal, state


def test_refresh_clicks_captcha_button_not_modal_close():
    from app.modules.invoices.infrastructure.adapters.gdt.gdt_auth import InvoiceGdtAuthMixin

    modal, state = _make_modal([_b64_svg_src("A"), _b64_svg_src("B")])
    cap_img = modal.locator("img[alt='captcha']")
    changed = InvoiceGdtAuthMixin()._refresh_captcha_image(_FakePage(), modal, cap_img, state["src"])
    assert changed is True
    assert state["clicks"][0] == REAL_REFRESH
    assert not any("close" in c or c == "span:has(svg)" for c in state["clicks"])


def test_acquire_captcha_solves_each_new_image_once(monkeypatch):
    from app.modules.invoices.infrastructure.adapters.gdt.gdt_auth import InvoiceGdtAuthMixin

    modal, state = _make_modal([_b64_svg_src("A"), _b64_svg_src("B"), _b64_svg_src("C"), _b64_svg_src("D")])
    auth = InvoiceGdtAuthMixin()
    seen_uris, seen_lens = [], []
    answers = iter([(None, None), (None, None), ("KXMB51", "consensus")])

    def fake_solve(data_uri, expected_len=None):
        seen_uris.append(data_uri)
        seen_lens.append(expected_len)
        return next(answers)

    monkeypatch.setattr(auth, "_render_svg_to_png", lambda svg: svg.encode())
    monkeypatch.setattr(auth, "_solve_with_ai_gateway", fake_solve)

    value, prov = auth._acquire_captcha_value(
        _FakePage(), modal, modal.locator("img[alt='captcha']"), {"svg": None}
    )
    assert value == "KXMB51"
    assert len(seen_uris) == 3 and len(set(seen_uris)) == 3  # 3 ảnh KHÁC NHAU
    assert seen_lens == [6, 6, 6]


def test_acquire_captcha_never_resolves_same_image_when_refresh_broken(monkeypatch):
    from app.modules.invoices.infrastructure.adapters.gdt.gdt_auth import InvoiceGdtAuthMixin

    modal, state = _make_modal([_b64_svg_src("A"), _b64_svg_src("B")], refresh_works=False)
    auth = InvoiceGdtAuthMixin()
    calls = []

    def fake_solve(data_uri, expected_len=None):
        calls.append(data_uri)
        return None, None

    monkeypatch.setattr(auth, "_render_svg_to_png", lambda svg: svg.encode())
    monkeypatch.setattr(auth, "_solve_with_ai_gateway", fake_solve)

    value, _ = auth._acquire_captcha_value(
        _FakePage(), modal, modal.locator("img[alt='captcha']"), {"svg": None}
    )
    assert value is None
    assert len(calls) == 1  # không tốn tiền đọc lại đúng ảnh cũ


# --------------------------------------------------------------------------
# 4. ai_error KHÔNG được tính vào bộ đếm ngắt mạch
# --------------------------------------------------------------------------
@pytest.mark.anyio
# "auth_error": payload THỰC TẾ phiên 04/10 (gdt_sync gộp ai_error thành auth_error ở cấp tổng)
# "ai_error"  : payload sau bản vá gdt_sync
@pytest.mark.parametrize("top_status", ["auth_error", "ai_error"])
async def test_ai_error_does_not_increment_auth_failures_or_trip_breaker(top_status):
    from app.modules.invoices.application.invoice_processing.auto_sync_scheduler import (
        InvoiceAutoSyncSchedulerService,
    )

    svc = MagicMock()
    svc.run_gdt_tax_sync.return_value = {
        "status": top_status,
        "message": "AI không thể giải mã Captcha với độ tin cậy tuyệt đối sau 3 lần đổi ảnh.",
        "connectors": [
            {
                "service": "gdt_portal",
                "tax_code": "0202111150",
                "status": "ai_error",
                "message": "AI không thể giải mã Captcha.",
            }
        ],
    }
    svc.increment_auth_failures.return_value = 2  # nếu bị gọi nhầm sẽ ngắt mạch ngay

    scheduler = InvoiceAutoSyncSchedulerService(
        invoice_service=svc,
        alert_email="contact@dinhsonconstruction.com",
        max_consecutive_auth_failures=2,
    )
    with patch(
        "app.modules.core.application.alert_service.BrevoEmailService.send_email",
        new_callable=AsyncMock,
    ) as mock_send:
        mock_send.return_value = {"success": True, "message_id": "<ai-err@brevo.com>"}
        res = await scheduler.execute_scheduled_sync(trigger_slot="01:00")

    svc.increment_auth_failures.assert_not_called()
    svc.deactivate_sync_config.assert_not_called()
    assert scheduler.is_paused is False
    assert res.get("circuit_breaker_tripped") is not True
    mock_send.assert_called_once()  # vẫn gửi email cảnh báo thông thường
