"""W20E5 — background_chain_render image_call_budget wiring (RED).

``render_node_image`` calls ``openai_client.images.edit`` /
``openai_client.images.generate``. Each attempt must reserve one budget
unit; cap exhaustion raises ``ImageCallBudgetExceeded`` and the function
must not swallow it.
"""
from __future__ import annotations

from unittest.mock import MagicMock

import pytest

from app.core.image_call_budget import (
    ImageCallBudget,
    ImageCallBudgetExceeded,
    install_budget,
    uninstall_budget,
)
from app.modules.pipeline.background_chain_render import render_node_image


@pytest.fixture(autouse=True)
def _isolate_budget():
    uninstall_budget()
    yield
    uninstall_budget()


def _ok_response(b64: str = "aGVsbG8="):
    resp = MagicMock()
    resp.data = [MagicMock(b64_json=b64)]
    return resp


def test_no_budget_keeps_legacy_behaviour(tmp_path):
    client = MagicMock()
    client.images.generate.return_value = _ok_response()
    out = tmp_path / "n.png"
    info = render_node_image(
        openai_client=client, image_model="gpt-image-2.5-sunburst", prompt="x",
        out_path=out, ref_paths=[], max_attempts=1,
    )
    assert info["status"] == "ok"
    assert client.images.generate.call_count == 1


def test_cap_zero_blocks_generate(tmp_path):
    install_budget(ImageCallBudget(cap=0))
    client = MagicMock()
    client.images.generate.return_value = _ok_response()
    out = tmp_path / "n.png"
    with pytest.raises(ImageCallBudgetExceeded):
        render_node_image(
            openai_client=client, image_model="gpt-image-2.5-sunburst", prompt="x",
            out_path=out, ref_paths=[], max_attempts=1,
        )
    assert client.images.generate.call_count == 0


def test_cap_zero_blocks_single_edit(tmp_path):
    install_budget(ImageCallBudget(cap=0))
    client = MagicMock()
    client.images.edit.return_value = _ok_response()
    ref = tmp_path / "r.png"
    ref.write_bytes(b"R")
    out = tmp_path / "n.png"
    with pytest.raises(ImageCallBudgetExceeded):
        render_node_image(
            openai_client=client, image_model="gpt-image-2.5-sunburst", prompt="x",
            out_path=out, ref_paths=[ref], max_attempts=1,
        )
    assert client.images.edit.call_count == 0


def test_cap_zero_blocks_multi_edit(tmp_path):
    install_budget(ImageCallBudget(cap=0))
    client = MagicMock()
    client.images.edit.return_value = _ok_response()
    r1 = tmp_path / "a.png"; r1.write_bytes(b"A")
    r2 = tmp_path / "b.png"; r2.write_bytes(b"B")
    out = tmp_path / "n.png"
    with pytest.raises(ImageCallBudgetExceeded):
        render_node_image(
            openai_client=client, image_model="gpt-image-2.5-sunburst", prompt="x",
            out_path=out, ref_paths=[r1, r2], max_attempts=1,
        )
    assert client.images.edit.call_count == 0


def test_cap_one_allows_one_call(tmp_path):
    budget = ImageCallBudget(cap=1)
    install_budget(budget)
    client = MagicMock()
    client.images.generate.return_value = _ok_response()
    out = tmp_path / "n.png"
    info = render_node_image(
        openai_client=client, image_model="gpt-image-2.5-sunburst", prompt="x",
        out_path=out, ref_paths=[], max_attempts=4,
    )
    assert info["status"] == "ok"
    assert client.images.generate.call_count == 1
    assert budget.snapshot()["used"] == 1


def test_cap_exhausts_on_transient_retry_loop_without_swallowing(monkeypatch, tmp_path):
    import app.modules.pipeline.background_chain_render as bcr
    monkeypatch.setattr(bcr.time, "sleep", lambda _s: None)

    budget = ImageCallBudget(cap=2)
    install_budget(budget)
    client = MagicMock()
    client.images.generate.side_effect = RuntimeError("upstream 502")
    out = tmp_path / "n.png"
    with pytest.raises(ImageCallBudgetExceeded):
        render_node_image(
            openai_client=client, image_model="gpt-image-2.5-sunburst", prompt="x",
            out_path=out, ref_paths=[], max_attempts=4,
        )
    assert client.images.generate.call_count == 2
    snap = budget.snapshot()
    assert snap["used"] == 2
    assert snap["denied"] == 1
