"""Tests for the general image scan API and helpers."""

import base64
import io
import json

import pytest
from fastapi.testclient import TestClient
from PIL import Image

from src.brokers.document_render_broker import DocumentRenderBroker
from src.models.api.image_scan import ScanBBox, normalize_category
from src.models.api.response import ExtractionMetadata
from src.models.llm.invocation import LLMInvocationResult
from src.processing.image_scan import (
    crop_logo,
    expand_box,
    map_box_to_image,
    parse_image_scan_response,
    parse_refined_bbox,
)


def _jpeg(size=(800, 500), color=(30, 60, 160), exif_orientation=None) -> bytes:
    image = Image.new("RGB", size, color)
    buffer = io.BytesIO()
    if exif_orientation is not None:
        exif = Image.Exif()
        exif[0x0112] = exif_orientation
        image.save(buffer, format="JPEG", exif=exif)
    else:
        image.save(buffer, format="JPEG")
    return buffer.getvalue()


def _model_json(**overrides) -> str:
    payload = {
        "documents": [
            {
                "image_indexes": [0],
                "document_type": "Visa credit card",
                "issuer": "Test Bank",
                "title": None,
                "summary": "A Visa credit card.",
                "language": "English",
                "fields": [{"label": "Expiry", "value": "08/28"}, {"label": "Amount", "value": 12}],
                "tables": [],
                "full_text": "TEST BANK\nVISA",
                "logos": [
                    {
                        "brand": "VISA",
                        "description": "blue wordmark",
                        "image_index": 0,
                        "bbox": {"x": 0.7, "y": 0.7, "w": 0.2, "h": 0.2},
                    }
                ],
            }
        ],
        "warnings": [],
    }
    payload.update(overrides)
    return json.dumps(payload)


REFINED_BOX = '{"bbox": {"x": 0.25, "y": 0.25, "w": 0.5, "h": 0.5}}'


@pytest.fixture
def patch_vision(monkeypatch):
    """Fake model. Main scan gets `content`; the small logo-refinement calls get `refine_content`."""
    calls = []

    def install(content: str, refine_content: str = "not json"):
        async def fake_vision(self, *, system_prompt, image_bytes_list, user_prompt, max_tokens=None):
            is_refine = "locate logos" in system_prompt
            calls.append(
                {
                    "kind": "refine" if is_refine else "scan",
                    "image_count": len(image_bytes_list),
                    "user_prompt": user_prompt,
                    "max_tokens": max_tokens,
                }
            )
            return LLMInvocationResult(
                content=refine_content if is_refine else content,
                metadata=ExtractionMetadata(model="gpt-test", total_tokens=10, latency_ms=100.0),
            )

        monkeypatch.setattr("src.foundation.llm_foundation_impl.LLMFoundation.vision", fake_vision)
        return calls

    return install


def test_scan_single_image_returns_fields_and_cropped_logo(client: TestClient, patch_vision):
    calls = patch_vision(_model_json())

    response = client.post(
        "/image-scan/extract",
        files=[("images", ("blob", io.BytesIO(_jpeg()), "image/jpeg"))],
    )

    assert response.status_code == 200
    body = response.json()
    assert body["image_count"] == 1
    document = body["documents"][0]
    assert document["document_type"] == "Visa credit card"
    assert document["metadata"]["fields"][1] == {"label": "Amount", "value": "12"}
    logo = document["metadata"]["logos"][0]
    assert logo["brand"] == "VISA"
    assert logo["logo_image_mime"] == "image/png"
    cropped = Image.open(io.BytesIO(base64.b64decode(logo["logo_image_base64"])))
    assert cropped.size[0] > 0 and cropped.size[1] > 0
    main = [c for c in calls if c["kind"] == "scan"]
    assert len(main) == 1
    assert main[0]["image_count"] == 1 and main[0]["max_tokens"] == 8000
    assert "1 image(s)" in main[0]["user_prompt"]


def test_logo_box_is_tightened_by_the_second_pass(client: TestClient, patch_vision):
    calls = patch_vision(_model_json(), refine_content=REFINED_BOX)

    response = client.post(
        "/image-scan/extract",
        files=[("images", ("blob", io.BytesIO(_jpeg((1000, 600))), "image/jpeg"))],
    )

    assert response.status_code == 200
    body = response.json()
    refine_calls = [c for c in calls if c["kind"] == "refine"]
    assert len(refine_calls) == 1 and refine_calls[0]["max_tokens"] == 300
    assert "VISA" in refine_calls[0]["user_prompt"]
    # first box x .7 w .2 -> roomy region .6..1.0 (w .4); refined .25..75% of it -> x .7, w .2 again
    logo = body["documents"][0]["metadata"]["logos"][0]
    assert logo["bbox"]["x"] == pytest.approx(0.7, abs=0.02)
    assert logo["bbox"]["w"] == pytest.approx(0.2, abs=0.02)
    assert logo["logo_image_base64"]
    assert body["usage"]["total_tokens"] == 20  # scan + one refinement
    assert body["usage"]["latency_ms"] == 200.0  # sequential: scan then the slowest refinement


def test_failed_second_pass_keeps_the_rough_box(client: TestClient, patch_vision):
    patch_vision(_model_json(), refine_content="the logo is not visible")

    response = client.post(
        "/image-scan/extract",
        files=[("images", ("blob", io.BytesIO(_jpeg((1000, 600))), "image/jpeg"))],
    )

    assert response.status_code == 200
    logo = response.json()["documents"][0]["metadata"]["logos"][0]
    assert logo["bbox"] == {"x": 0.7, "y": 0.7, "w": 0.2, "h": 0.2}
    assert logo["logo_image_base64"]


def test_scan_two_images_one_document(client: TestClient, patch_vision):
    content = json.loads(_model_json())
    content["documents"][0]["image_indexes"] = [0, 1]
    calls = patch_vision(json.dumps(content))

    response = client.post(
        "/image-scan/extract",
        files=[
            ("images", ("front.jpg", io.BytesIO(_jpeg()), "image/jpeg")),
            ("images", ("back.jpg", io.BytesIO(_jpeg()), "image/jpeg")),
        ],
    )

    assert response.status_code == 200
    assert [c for c in calls if c["kind"] == "scan"][0]["image_count"] == 2
    assert response.json()["documents"][0]["metadata"]["image_indexes"] == [0, 1]


def test_scan_rejects_too_many_images(client: TestClient, patch_vision):
    patch_vision(_model_json())
    files = [("images", (f"{i}.jpg", io.BytesIO(_jpeg((100, 100))), "image/jpeg")) for i in range(7)]

    response = client.post("/image-scan/extract", files=files)

    assert response.status_code == 400
    assert "maximum of 6" in response.json()["detail"]


def test_scan_rejects_non_image(client: TestClient, patch_vision):
    patch_vision(_model_json())

    response = client.post(
        "/image-scan/extract",
        files=[("images", ("notes.pdf", io.BytesIO(b"%PDF-1.4 not an image"), "application/pdf"))],
    )

    assert response.status_code == 400
    assert "Image 1" in response.json()["detail"]


def test_scan_requires_an_image(client: TestClient):
    response = client.post("/image-scan/extract")

    assert response.status_code == 422


def test_scan_bad_model_output_is_502(client: TestClient, patch_vision):
    patch_vision("sorry, I cannot read this")

    response = client.post(
        "/image-scan/extract",
        files=[("images", ("a.jpg", io.BytesIO(_jpeg()), "image/jpeg"))],
    )

    assert response.status_code == 502


def test_parse_is_lenient_about_bad_pieces():
    output = parse_image_scan_response(
        json.dumps(
            {
                "documents": [
                    {
                        "document_type": "Receipt",
                        "fields": [{"label": "Total", "value": 5.5}, "junk"],
                        "logos": [{"brand": "Shop", "bbox": {"x": "bad"}}],
                        "image_indexes": [0, "x", True],
                    },
                    "not a document",
                ],
                "warnings": ["blurry", None, 3],
            }
        )
    )

    assert len(output.documents) == 1
    document = output.documents[0]
    assert document.fields[0].value == "5.5"
    assert document.logos[0].bbox is None
    assert document.image_indexes == [0]
    assert output.warnings == ["blurry", "3"]


def test_bbox_is_clamped_into_the_image():
    output = parse_image_scan_response(
        json.dumps({"documents": [{"logos": [{"bbox": {"x": 0.9, "y": -0.2, "w": 0.5, "h": 2}}]}]})
    )

    bbox = output.documents[0].logos[0].bbox
    assert bbox is not None
    assert 0 <= bbox.x <= 1 and 0 <= bbox.y <= 1
    assert bbox.x + bbox.w <= 1 and bbox.y + bbox.h <= 1


def test_crop_logo_cuts_the_requested_region():
    image = Image.new("RGB", (1000, 600), (255, 255, 255))
    for x in range(700, 900):
        for y in range(420, 540):
            image.putpixel((x, y), (200, 0, 0))
    buffer = io.BytesIO()
    image.save(buffer, format="PNG")

    cropped = crop_logo(buffer.getvalue(), ScanBBox(x=0.7, y=0.7, w=0.2, h=0.2), pad_ratio=0.0)

    assert cropped is not None
    result = Image.open(io.BytesIO(cropped))
    assert result.size == (200, 120)
    assert result.getpixel((100, 60)) == (200, 0, 0)


def test_crop_logo_skips_tiny_boxes():
    buffer = io.BytesIO()
    Image.new("RGB", (1000, 600), (255, 255, 255)).save(buffer, format="PNG")

    assert crop_logo(buffer.getvalue(), ScanBBox(x=0.5, y=0.5, w=0.001, h=0.001), pad_ratio=0.0) is None


def test_normalized_image_applies_exif_rotation_and_caps_size():
    broker = DocumentRenderBroker()
    # EXIF orientation 6 = the photo must be rotated 90 degrees clockwise to be upright.
    landscape = _jpeg(size=(4000, 2000), exif_orientation=6)

    png = broker.normalized_image_png(landscape, max_edge=1000)

    result = Image.open(io.BytesIO(png))
    assert result.format == "PNG"
    assert result.size == (500, 1000)


def test_normalized_image_rejects_non_images_and_unsupported_formats():
    broker = DocumentRenderBroker()
    gif = io.BytesIO()
    Image.new("RGB", (10, 10)).save(gif, format="GIF")

    with pytest.raises(ValueError, match="not a readable image"):
        broker.normalized_image_png(b"%PDF-1.4", max_edge=500)
    with pytest.raises(ValueError, match="unsupported image format GIF"):
        broker.normalized_image_png(gif.getvalue(), max_edge=500)


def test_box_helpers_round_trip():
    region = expand_box(ScanBBox(x=0.6, y=0.6, w=0.2, h=0.2), 0.5)
    assert (region.x, region.y) == pytest.approx((0.5, 0.5))
    assert (region.w, region.h) == pytest.approx((0.4, 0.4))

    local = parse_refined_bbox('{"bbox": {"x": 0.25, "y": 0.25, "w": 0.5, "h": 0.5}}')
    assert local is not None
    mapped = map_box_to_image(region, local)
    assert mapped.x == pytest.approx(0.6) and mapped.w == pytest.approx(0.2)

    assert parse_refined_bbox('{"bbox": null}') is None
    assert parse_refined_bbox("nonsense") is None
    assert parse_refined_bbox('{"bbox": {"x": 0.1, "y": 0.1, "w": 0, "h": 0.2}}') is None


def _scan(client, patch_vision, doc: dict):
    patch_vision(json.dumps({"documents": [doc], "warnings": []}))
    response = client.post(
        "/image-scan/extract",
        files=[("images", ("blob", io.BytesIO(_jpeg((1000, 600))), "image/jpeg"))],
    )
    assert response.status_code == 200
    return response.json()["documents"][0]


def test_category_labels_are_normalised():
    assert normalize_category("visiting_card") == "visiting_card"
    assert normalize_category("Business Card") == "visiting_card"
    assert normalize_category("news article") == "article"
    assert normalize_category("post") == "poster"
    assert normalize_category("Flyer") == "poster"
    assert normalize_category("payment card") == "other"
    assert normalize_category(None) == "other"


def test_visiting_card_returns_keyed_items(client: TestClient, patch_vision):
    document = _scan(
        client,
        patch_vision,
        {
            "category": "visiting_card",
            "document_type": "Business visiting card",
            "card_data": {"name": "Asha Rao", "date_of_birth": "14 Mar 1988", "phone": ["+971 50 000 0000", "+971 4 000 0000"], "empty": ""},
        },
    )

    assert document["category"] == "visiting_card"
    assert document["card_data"] == {
        "name": "Asha Rao",
        "date_of_birth": "14 Mar 1988",
        "phone": "+971 50 000 0000, +971 4 000 0000",
    }
    assert document["poster"] is None and "article" not in document


def test_visiting_card_data_is_built_from_fields_when_model_skips_it(client: TestClient, patch_vision):
    document = _scan(
        client,
        patch_vision,
        {
            "category": "visiting_card",
            "fields": [
                {"label": "Name", "value": "Asha Rao"},
                {"label": "Phone", "value": "111"},
                {"label": "Phone", "value": "222"},
                {"label": "E-mail", "value": "asha@example.com"},
            ],
        },
    )

    assert document["card_data"] == {"name": "Asha Rao", "phone": "111", "phone_2": "222", "e_mail": "asha@example.com"}


def test_article_is_plain_text_in_metadata_with_no_article_object(client: TestClient, patch_vision):
    document = _scan(
        client,
        patch_vision,
        {
            "category": "news article",
            "document_type": "Newspaper article",
            "title": "Port volumes rise",
            "full_text": "First paragraph.\n\nSecond paragraph.",
            "fields": [{"label": "Author", "value": "A. Writer"}],
        },
    )

    assert document["category"] == "article"
    assert "article" not in document
    assert document["card_data"] is None and document["poster"] is None
    assert document["metadata"]["title"] == "Port volumes rise"
    assert document["metadata"]["full_text"] == "First paragraph.\n\nSecond paragraph."
    assert document["metadata"]["fields"] == [{"label": "Author", "value": "A. Writer"}]


def test_response_has_the_agreed_shape(client: TestClient, patch_vision):
    patch_vision(_model_json())

    body = client.post(
        "/image-scan/extract",
        files=[("images", ("blob", io.BytesIO(_jpeg()), "image/jpeg"))],
    ).json()

    assert set(body) == {"scan_id", "image_count", "documents", "warnings", "usage"}
    assert len(body["scan_id"]) == 32
    assert set(body["documents"][0]) == {"category", "document_type", "issuer", "card_data", "poster", "metadata"}
    assert set(body["documents"][0]["metadata"]) == {
        "image_indexes", "title", "summary", "language", "fields", "tables", "full_text", "logos",
    }


def test_poster_returns_subject_and_details(client: TestClient, patch_vision):
    document = _scan(
        client,
        patch_vision,
        {
            "category": "poster",
            "poster": {"subject": "Annual Trade Fair", "details": [{"label": "Date", "value": "12 Nov 2026"}, "junk"]},
        },
    )

    assert document["category"] == "poster"
    assert document["poster"]["subject"] == "Annual Trade Fair"
    assert document["poster"]["details"] == [{"label": "Date", "value": "12 Nov 2026"}]


def test_unknown_document_is_other_with_no_category_objects(client: TestClient, patch_vision):
    document = _scan(client, patch_vision, {"document_type": "Visa credit card"})

    assert document["category"] == "other"
    assert document["card_data"] is None and document["poster"] is None and "article" not in document
