"""Phase 3 — tests for FalAdapter.direct_submit_image() (gpt-image-2).

All tests mock the fal transport — NO real fal.ai calls. Phase 5 is the
only live-call gate.
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest

from recoil.execution.providers.base import UnifiedVideoPayload
from recoil.execution.providers.fal import (
    FalAdapter,
    _GPT_IMAGE_2_ACTION_MAP,
    _GPT_IMAGE_2_DEFAULT_QUALITY,
    _infer_gpt_image_2_action,
    _resolve_gpt_image_2_size,
    _extract_fal_billing_cost_usd,
)


def _payload(
    model_id="gpt-image-2",
    prompt="x",
    aspect_ratio="9:16",
    reference_images=None,
    hints=None,
) -> UnifiedVideoPayload:
    """Build a minimal UnifiedVideoPayload for tests."""
    return UnifiedVideoPayload(
        prompt=prompt,
        aspect_ratio=aspect_ratio,
        duration_s=0,
        resolution="720p",
        shot_id="test",
        model_id=model_id,
        reference_images=reference_images or [],
        hints=hints,
    )


def test_infer_action_t2i_when_no_refs():
    assert _infer_gpt_image_2_action(_payload()) == "gpt_image_2_t2i"


def test_infer_action_edit_when_reference_images_present():
    p = _payload(reference_images=["http://example.com/a.png"])
    assert _infer_gpt_image_2_action(p) == "gpt_image_2_edit"


def test_resolve_size_aspect_ratios():
    assert _resolve_gpt_image_2_size("1:1") == "1024x1024"
    assert _resolve_gpt_image_2_size("9:16") == "1024x1536"
    assert _resolve_gpt_image_2_size("16:9") == "1536x1024"
    assert _resolve_gpt_image_2_size(None) == "1024x1024"
    assert _resolve_gpt_image_2_size("21:9") == "1024x1024"  # falls back


def test_default_quality_constant_is_medium():
    assert _GPT_IMAGE_2_DEFAULT_QUALITY == "medium"


def test_action_map_endpoints():
    assert _GPT_IMAGE_2_ACTION_MAP["gpt_image_2_t2i"] == "fal-ai/gpt-image-2"
    assert _GPT_IMAGE_2_ACTION_MAP["gpt_image_2_edit"] == "fal-ai/gpt-image-2/edit"


def test_extract_cost_flat_cost_usd():
    assert _extract_fal_billing_cost_usd({"cost_usd": 0.012}) == 0.012


def test_extract_cost_flat_total_cost_usd():
    assert _extract_fal_billing_cost_usd({"total_cost_usd": 0.034}) == 0.034


def test_extract_cost_nested_billing_cost_usd():
    assert _extract_fal_billing_cost_usd({"billing": {"cost_usd": 0.05}}) == 0.05


def test_extract_cost_nested_billing_total_cost_usd():
    assert _extract_fal_billing_cost_usd({"billing": {"total_cost_usd": 0.06}}) == 0.06


def test_extract_cost_missing_returns_zero_no_raise():
    assert _extract_fal_billing_cost_usd({}) == 0.0
    assert _extract_fal_billing_cost_usd({"billing": {}}) == 0.0
    assert _extract_fal_billing_cost_usd({"images": [{"url": "..."}]}) == 0.0
    assert _extract_fal_billing_cost_usd("not_a_dict") == 0.0  # type: ignore[arg-type]
    assert _extract_fal_billing_cost_usd(None) == 0.0  # type: ignore[arg-type]


@patch("recoil.execution.providers.fal._fal_transport.FalTransport")
@patch("recoil.execution.providers.fal.urllib.request.urlopen")
def test_direct_submit_image_t2i_happy_path(mock_urlopen, mock_transport_cls):
    """No refs → t2i endpoint, default quality 'medium', cost estimated.

    Contract changed 2026-05-26: fal does not expose gpt-image-2 cost in
    the response body or headers (verified against the /edit endpoint docs).
    The adapter now stamps cost from the per-tier tariff estimator instead
    of attempting to extract from fal billing. Any `billing` block in the
    response is ignored for this endpoint.
    """
    transport = MagicMock()
    mock_transport_cls.return_value = transport
    transport.submit_queue.return_value = {
        "request_id": "req-abc",
        "status_url": "https://queue.fal.run/fal-ai/gpt-image-2/requests/req-abc/status",
        "response_url": "https://queue.fal.run/fal-ai/gpt-image-2/requests/req-abc",
    }
    transport.poll_status.return_value = {"status": "COMPLETED"}
    transport.fetch_result.return_value = {
        "images": [{"url": "https://cdn.fal.ai/img-abc.png"}],
        "billing": {"cost_usd": 0.012},  # intentionally ignored — see docstring
    }
    mock_resp = MagicMock()
    mock_resp.read.return_value = b"\x89PNG\r\n\x1a\nFAKE"
    mock_urlopen.return_value.__enter__.return_value = mock_resp

    adapter = FalAdapter()
    raw = adapter.direct_submit_image(_payload(aspect_ratio="9:16"))

    assert raw["image_bytes"].startswith(b"\x89PNG")
    assert raw["native_id"] == "req-abc"
    # 9:16 → 1024x1536 medium → $0.05 per fal.ai published tariff (2026-05-28)
    assert raw["cost_usd"] == 0.05  # 1024×1536 medium per fal-blog tariff (2026-05-28)
    assert raw["metadata"]["endpoint"] == "fal-ai/gpt-image-2"
    assert raw["metadata"]["size"] == "1024x1536"
    assert raw["metadata"]["quality"] == "medium"
    assert raw["metadata"]["cost_source"] == "estimated_from_tariff"

    submit_call = transport.submit_queue.call_args
    endpoint_arg, body_arg = submit_call.args
    assert endpoint_arg == "fal-ai/gpt-image-2"
    assert body_arg["prompt"] == "x"
    # fal wire shape: image_size is {"width": W, "height": H} object,
    # NOT a "WxH" string. The pre-2026-05-28 adapter sent body["size"]
    # which fal silently ignored, defaulting all outputs to 1024×768.
    assert body_arg["image_size"] == {"width": 1024, "height": 1536}
    assert "size" not in body_arg  # ensure old field name not present
    assert body_arg["quality"] == "medium"
    assert body_arg["num_images"] == 1
    assert body_arg["output_format"] == "png"
    assert "image_urls" not in body_arg


@patch("recoil.execution.providers.fal._fal_transport.FalTransport")
@patch("recoil.execution.providers.fal.urllib.request.urlopen")
def test_direct_submit_image_edit_path(mock_urlopen, mock_transport_cls):
    """reference_images → edit endpoint, image_urls in body."""
    transport = MagicMock()
    mock_transport_cls.return_value = transport
    transport.submit_queue.return_value = {
        "request_id": "req-edit",
        "status_url": "https://queue.fal.run/.../status",
        "response_url": "https://queue.fal.run/.../result",
    }
    transport.poll_status.return_value = {"status": "COMPLETED"}
    transport.fetch_result.return_value = {
        "images": [{"url": "https://cdn.fal.ai/edit-out.png"}],
        "billing": {"cost_usd": 0.05},
    }
    mock_resp = MagicMock()
    mock_resp.read.return_value = b"\x89PNG\r\n\x1a\nEDIT"
    mock_urlopen.return_value.__enter__.return_value = mock_resp

    adapter = FalAdapter()
    raw = adapter.direct_submit_image(
        _payload(
            aspect_ratio="16:9",
            reference_images=["http://example.com/src.png"],
        )
    )

    assert raw["metadata"]["endpoint"] == "fal-ai/gpt-image-2/edit"
    assert raw["metadata"]["size"] == "1536x1024"
    submit_call = transport.submit_queue.call_args
    endpoint_arg, body_arg = submit_call.args
    assert endpoint_arg == "fal-ai/gpt-image-2/edit"
    assert body_arg["image_urls"] == ["http://example.com/src.png"]


@patch("recoil.execution.providers.fal._fal_transport.FalTransport")
@patch("recoil.execution.providers.fal.urllib.request.urlopen")
def test_direct_submit_image_edit_path_passes_all_refs(
    mock_urlopen,
    mock_transport_cls,
):
    """REGRESSION: multi-ref must reach the edit endpoint, NOT be silently
    truncated to the first ref. Prior `[:1]` cap in fal.py:111 produced
    dramatically worse identity anchoring than the model can deliver.
    See feedback-cross-check-consult-against-substrate.md (2026-05-25).
    """
    transport = MagicMock()
    mock_transport_cls.return_value = transport
    transport.submit_queue.return_value = {
        "request_id": "req-edit-multi",
        "status_url": "https://queue.fal.run/.../status",
        "response_url": "https://queue.fal.run/.../result",
    }
    transport.poll_status.return_value = {"status": "COMPLETED"}
    transport.fetch_result.return_value = {
        "images": [{"url": "https://cdn.fal.ai/multi-edit.png"}],
        "billing": {"cost_usd": 0.21},
    }
    mock_resp = MagicMock()
    mock_resp.read.return_value = b"\x89PNG\r\n\x1a\nMULTI"
    mock_urlopen.return_value.__enter__.return_value = mock_resp

    refs = [
        "http://example.com/hero.jpg",
        "http://example.com/front.jpg",
        "http://example.com/profile.jpg",
        "http://example.com/closeup.jpg",
        "http://example.com/back.png",
    ]
    adapter = FalAdapter()
    adapter.direct_submit_image(_payload(aspect_ratio="16:9", reference_images=refs))

    submit_call = transport.submit_queue.call_args
    _endpoint_arg, body_arg = submit_call.args
    assert body_arg["image_urls"] == refs, (
        f"all 5 refs must reach the endpoint, not be truncated. "
        f"got {len(body_arg['image_urls'])} refs: {body_arg['image_urls']}"
    )


@patch("recoil.execution.providers.fal._fal_transport.FalTransport")
@patch("recoil.execution.providers.fal.urllib.request.urlopen")
def test_direct_submit_image_polls_until_completed(mock_urlopen, mock_transport_cls):
    """IN_PROGRESS → IN_PROGRESS → COMPLETED. Verify poll count > 1."""
    transport = MagicMock()
    mock_transport_cls.return_value = transport
    transport.submit_queue.return_value = {
        "request_id": "req-poll",
        "status_url": "https://queue.fal.run/.../status",
        "response_url": "https://queue.fal.run/.../result",
    }
    transport.poll_status.side_effect = [
        {"status": "IN_PROGRESS"},
        {"status": "IN_PROGRESS"},
        {"status": "COMPLETED"},
    ]
    transport.fetch_result.return_value = {
        "images": [{"url": "https://cdn.fal.ai/x.png"}],
        "billing": {"cost_usd": 0.009},  # ignored — see t2i_happy_path docstring
    }
    mock_urlopen.return_value.__enter__.return_value = MagicMock(
        read=MagicMock(return_value=b"\x89PNG"),
    )

    adapter = FalAdapter()
    with patch("recoil.execution.providers.fal.time.sleep"):
        raw = adapter.direct_submit_image(_payload())
    # _payload() defaults to 9:16 → 1024x1536 → fal-blog tariff: medium = $0.05
    assert raw["cost_usd"] == 0.05  # 1024×1536 medium per fal-blog tariff (2026-05-28)
    assert raw["metadata"]["cost_source"] == "estimated_from_tariff"
    assert transport.poll_status.call_count == 3


@patch("recoil.execution.providers.fal._fal_transport.FalTransport")
@patch("recoil.execution.providers.fal.urllib.request.urlopen")
def test_direct_submit_image_survives_transient_poll_error(
    mock_urlopen,
    mock_transport_cls,
):
    transport = MagicMock()
    mock_transport_cls.return_value = transport
    transport.submit_queue.return_value = {
        "request_id": "req-transient",
        "status_url": "https://queue.fal.run/.../status",
        "response_url": "https://queue.fal.run/.../result",
    }
    transport.poll_status.side_effect = [
        RuntimeError("temporary 502"),
        {"status": "COMPLETED"},
    ]
    transport.fetch_result.return_value = {
        "images": [{"url": "https://cdn.fal.ai/transient.png"}],
    }
    mock_urlopen.return_value.__enter__.return_value = MagicMock(
        read=MagicMock(return_value=b"\x89PNG"),
    )

    adapter = FalAdapter()
    with patch("recoil.execution.providers.fal.time.sleep"):
        raw = adapter.direct_submit_image(_payload())

    assert raw["native_id"] == "req-transient"
    assert transport.poll_status.call_count == 2


@patch("recoil.execution.providers.fal._fal_transport.FalTransport")
def test_direct_submit_image_failed_status_raises(mock_transport_cls):
    transport = MagicMock()
    mock_transport_cls.return_value = transport
    transport.submit_queue.return_value = {
        "request_id": "req-fail",
        "status_url": "https://queue.fal.run/.../status",
        "response_url": "https://queue.fal.run/.../result",
    }
    transport.poll_status.return_value = {
        "status": "FAILED",
        "error": "content policy violation",
    }
    adapter = FalAdapter()
    with pytest.raises(RuntimeError, match="FAILED"):
        adapter.direct_submit_image(_payload())


def test_direct_submit_image_non_gpt_image_2_raises_not_implemented():
    """Phase 3 v1 gates this method to gpt-image-2 only."""
    adapter = FalAdapter()
    p = _payload(model_id="seedream-v4.5")
    with pytest.raises(NotImplementedError, match="seedream-v4.5"):
        adapter.direct_submit_image(p)


@patch("recoil.execution.providers.fal._fal_transport.FalTransport")
@patch("recoil.execution.providers.fal.urllib.request.urlopen")
def test_direct_submit_image_missing_billing_still_estimates_cost(
    mock_urlopen,
    mock_transport_cls,
):
    """Contract changed 2026-05-26: gpt-image-2 cost is always estimated
    from the per-tier tariff (fal doesn't expose this endpoint's billing).
    A response with no `billing` block is therefore the normal case, not an
    error — the estimator stamps a non-zero cost regardless.

    Supersedes the prior Gemini P0-2 'missing billing returns 0.0' contract.
    """
    transport = MagicMock()
    mock_transport_cls.return_value = transport
    transport.submit_queue.return_value = {
        "request_id": "req-nobill",
        "status_url": "https://queue.fal.run/.../status",
        "response_url": "https://queue.fal.run/.../result",
    }
    transport.poll_status.return_value = {"status": "COMPLETED"}
    transport.fetch_result.return_value = {
        "images": [{"url": "https://cdn.fal.ai/y.png"}],
        # no `billing` key — the realistic gpt-image-2 response shape
    }
    mock_urlopen.return_value.__enter__.return_value = MagicMock(
        read=MagicMock(return_value=b"\x89PNG"),
    )
    adapter = FalAdapter()
    raw = adapter.direct_submit_image(_payload())
    # 9:16 medium → $0.05 per fal-blog tariff. Never 0.0 for gpt-image-2.
    assert raw["cost_usd"] == 0.05  # 1024×1536 medium per fal-blog tariff (2026-05-28)
    assert raw["metadata"]["cost_source"] == "estimated_from_tariff"
