"""Uploaded card documents are sanitised and rewritten (task 5.9, design D4, D5).

The sanitiser tests are pure — no database, no HTTP — because what they assert is a decision
about a string. The route tests below them exist for the parts a unit test cannot reach: that
the decision is actually applied on the way in, and that the one-published-per-event rule is
the database's rather than the endpoint's.

Worth stating plainly, because it shapes what is tested: the sanitiser is defence in depth,
not a hostile-input boundary. Cards are authored by our own design team. So these assert what
a careless export can carry — a leftover analytics snippet, a font pulled from a CDN, an
`onclick` from a prototype — rather than trying to prove the parser cannot be evaded.
"""

import uuid
from typing import Any

import pytest
from httpx import ASGITransport, AsyncClient

from app.main import app
from app.services import card_html

from .conftest import png_bytes as preview_png
from .conftest import requires_db

# ------------------------------------------------------------------ sanitising


def test_scripts_are_removed_with_their_contents() -> None:
    result = card_html.sanitise("<div>Hi<script>alert(1)</script></div>", {})
    assert "script" not in result.html
    assert "alert" not in result.html
    assert "script" in result.removed


def test_event_handlers_are_stripped_but_the_element_stays() -> None:
    """The visual is the point of the file; only the capability comes out."""
    result = card_html.sanitise('<h1 onclick="steal()" class="title">Names</h1>', {})
    assert "onclick" not in result.html
    assert 'class="title"' in result.html
    assert "Names" in result.html


def test_a_base_tag_is_removed() -> None:
    """`<base>` silently re-points every relative URL in the page it lands in, which would
    undo the rewriting below — it is a correctness problem before it is a security one."""
    result = card_html.sanitise('<base href="https://cdn.example.com/"><div>x</div>', {})
    assert "base" not in result.html


@pytest.mark.parametrize(
    "reference",
    [
        '<img src="https://cdn.example.com/flourish.png">',
        '<img src="//cdn.example.com/flourish.png">',  # protocol-relative
        '<div style="background: url(https://fonts.example.com/x.woff2)"></div>',
    ],
)
def test_an_external_reference_is_rejected_by_name(reference: str) -> None:
    """Rejected, not stripped. Silently dropping the background image would publish a card
    the designer never saw, and they would hear about it from the customer."""
    with pytest.raises(card_html.CardRejectedError) as caught:
        card_html.sanitise(reference, {})
    assert "example.com" in str(caught.value)


def test_relative_references_are_rewritten_to_stored_urls() -> None:
    """The bug this exists for (design D5): a card referencing `paper.svg`, injected into a
    page served from `/i/{token}`, resolves to `/i/paper.svg` and 404s."""
    assets = {"paper.svg": "/media/cards/e/abc123.svg"}
    result = card_html.sanitise(
        '<div style="background: url(paper.svg)"><img src="./paper.svg"></div>', assets
    )
    assert result.html.count("/media/cards/e/abc123.svg") == 2
    assert "paper.svg" not in result.html.replace("/media/cards/e/abc123.svg", "")
    assert result.missing == []


def test_references_inside_a_style_block_are_rewritten() -> None:
    """A stylesheet's `url()` is the most common way artwork is referenced, and it is text
    content rather than an attribute — a rewriter that only walks attributes misses it."""
    assets = {"border.svg": "/media/cards/e/def456.svg"}
    result = card_html.sanitise(
        "<style>.card { border-image: url(border.svg) }</style><div class='card'></div>", assets
    )
    assert "/media/cards/e/def456.svg" in result.html


def test_a_reference_with_no_uploaded_file_is_reported() -> None:
    result = card_html.sanitise('<img src="missing.png">', {})
    assert result.missing == ["missing.png"]


def test_every_missing_reference_is_reported_at_once() -> None:
    """One at a time would mean one upload round trip per missing file."""
    result = card_html.sanitise('<img src="a.png"><img src="b.png">', {})
    assert result.missing == ["a.png", "b.png"]


def test_data_uris_and_fragments_are_left_alone() -> None:
    """Both are self-contained: they reach nothing outside the document."""
    source = '<img src="data:image/png;base64,iVBORw0KGgo="><use href="#gradient-1"/>'
    result = card_html.sanitise(source, {})
    assert "data:image/png;base64,iVBORw0KGgo=" in result.html
    assert "#gradient-1" in result.html
    assert result.missing == []


def test_an_already_rewritten_document_survives_a_second_pass() -> None:
    """Re-uploading a stored document must not treat its own URLs as missing files."""
    result = card_html.sanitise('<img src="/media/cards/e/abc123.svg">', {})
    assert result.missing == []
    assert "/media/cards/e/abc123.svg" in result.html


def test_the_visual_document_survives_intact() -> None:
    """The counterweight to every test above: sanitising must not damage the design."""
    source = (
        "<div class='card'><style>.card{color:#7a5c2e;animation:glow 3s infinite}</style>"
        "<h1>Nazifa &amp; Abdullah</h1><p>24 September</p></div>"
    )
    result = card_html.sanitise(source, {})
    assert "Nazifa &amp; Abdullah" in result.html
    assert "animation:glow 3s infinite" in result.html
    assert "#7a5c2e" in result.html


# ------------------------------------------------------------------ through the API

CARD = b"<div class='card'><style>.card{background:url(paper.svg)}</style><h1>Names</h1></div>"
SVG = (
    b'<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8">'
    b'<rect width="8" height="8"/></svg>'
)


async def _event_id(client: AsyncClient) -> Any:
    events = await client.get("/api/admin/events")
    assert events.status_code == 200, events.text
    return events.json()[0]["id"]


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_upload_stores_assets_and_rewrites_the_document() -> None:
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        event_id = await _event_id(client)
        response = await client.post(
            f"/api/admin/events/{event_id}/card-designs",
            files=[
                ("document", ("card.html", CARD, "text/html")),
                ("assets", ("paper.svg", SVG, "image/svg+xml")),
            ],
        )
        assert response.status_code == 201, response.text
        design = response.json()
        assert design["status"] == "draft"  # uploading is not publishing
        assert design["assets"]["paper.svg"].startswith("/media/cards/")
        assert design["assets"]["paper.svg"] in design["config"]["document"]

        await client.delete(f"/api/admin/card-designs/{design['id']}")


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_upload_without_the_companion_file_is_refused_by_name() -> None:
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        event_id = await _event_id(client)
        response = await client.post(
            f"/api/admin/events/{event_id}/card-designs",
            files=[("document", ("card.html", CARD, "text/html"))],
        )
        assert response.status_code == 422
        assert "paper.svg" in response.json()["detail"]


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_an_asset_of_the_wrong_type_is_refused() -> None:
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        event_id = await _event_id(client)
        response = await client.post(
            f"/api/admin/events/{event_id}/card-designs",
            files=[
                ("document", ("card.html", CARD, "text/html")),
                ("assets", ("paper.svg", b"MZ\x90\x00", "application/x-msdownload")),
            ],
        )
        assert response.status_code == 422
        assert "paper.svg" in response.json()["detail"]


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_only_one_design_is_published_per_event() -> None:
    """The rule is a partial unique index, not this endpoint remembering to demote the old
    one — so publishing a second design must leave exactly one published, not two."""
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        event_id = await _event_id(client)
        created = []
        for _ in range(2):
            response = await client.post(
                f"/api/admin/events/{event_id}/card-designs",
                files=[
                    ("document", ("card.html", CARD, "text/html")),
                    ("assets", ("paper.svg", SVG, "image/svg+xml")),
                ],
            )
            assert response.status_code == 201, response.text
            created.append(response.json())

        assert created[1]["version"] > created[0]["version"]  # versions never reused

        for design in created:
            published = await client.post(f"/api/admin/card-designs/{design['id']}/publish")
            assert published.status_code == 200, published.text

        listing = await client.get(f"/api/admin/events/{event_id}/card-designs")
        live = [d for d in listing.json() if d["status"] == "published"]
        assert len(live) == 1
        assert live[0]["id"] == created[1]["id"]

        # A live design cannot be deleted out from under the guests looking at it.
        blocked = await client.delete(f"/api/admin/card-designs/{created[1]['id']}")
        assert blocked.status_code == 409

        await client.post(f"/api/admin/card-designs/{created[1]['id']}/unpublish")
        for design in created:
            await client.delete(f"/api/admin/card-designs/{design['id']}")


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_uploading_to_an_unknown_event_is_not_found() -> None:
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.post(
            f"/api/admin/events/{uuid.uuid4()}/card-designs",
            files=[("document", ("card.html", b"<div>x</div>", "text/html"))],
        )
        assert response.status_code == 404


# ------------------------------------------------------ link-preview image (task 2.8, D2)


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_a_preview_image_is_stored_in_config_and_not_in_assets() -> None:
    """It is not a file the card references, so it must not enter the rewrite table."""
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        event_id = await _event_id(client)
        response = await client.post(
            f"/api/admin/events/{event_id}/card-designs",
            files=[
                ("document", ("card.html", CARD, "text/html")),
                ("assets", ("paper.svg", SVG, "image/svg+xml")),
                ("og_image", ("share.png", preview_png(1200, 630), "image/png")),
            ],
        )
        assert response.status_code == 201, response.text
        design = response.json()

        preview = design["preview_image"]
        assert preview["width"] == 1200 and preview["height"] == 630
        assert preview["url"].startswith("/media/cards/")

        assert list(design["assets"]) == ["paper.svg"]
        assert design["config"]["asset_names"] == ["paper.svg"]
        assert preview["url"] not in design["config"]["document"]
        assert design["warnings"] == []

        await client.delete(f"/api/admin/card-designs/{design['id']}")


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_a_design_without_a_preview_image_still_uploads() -> None:
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        event_id = await _event_id(client)
        response = await client.post(
            f"/api/admin/events/{event_id}/card-designs",
            files=[
                ("document", ("card.html", CARD, "text/html")),
                ("assets", ("paper.svg", SVG, "image/svg+xml")),
            ],
        )
        assert response.status_code == 201, response.text
        design = response.json()
        assert design["preview_image"] is None

        published = await client.post(f"/api/admin/card-designs/{design['id']}/publish")
        assert published.status_code == 200, published.text
        assert published.json()["preview_image"] is None

        await client.post(f"/api/admin/card-designs/{design['id']}/unpublish")
        await client.delete(f"/api/admin/card-designs/{design['id']}")


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_a_companion_named_og_png_does_not_claim_the_role() -> None:
    """The role is carried by the upload field, not by the filename — otherwise a designer
    could take the preview slot by accident and never find out why."""
    card = b"<div><img src='og.png'></div>"
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        event_id = await _event_id(client)
        response = await client.post(
            f"/api/admin/events/{event_id}/card-designs",
            files=[
                ("document", ("card.html", card, "text/html")),
                ("assets", ("og.png", preview_png(1200, 630), "image/png")),
            ],
        )
        assert response.status_code == 201, response.text
        design = response.json()
        assert design["preview_image"] is None
        assert "og.png" in design["assets"]

        await client.delete(f"/api/admin/card-designs/{design['id']}")


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_an_unusable_preview_image_is_refused_by_name() -> None:
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        event_id = await _event_id(client)
        response = await client.post(
            f"/api/admin/events/{event_id}/card-designs",
            files=[
                ("document", ("card.html", CARD, "text/html")),
                ("assets", ("paper.svg", SVG, "image/svg+xml")),
                ("og_image", ("share.svg", SVG, "image/svg+xml")),
            ],
        )
        assert response.status_code == 422
        detail = response.json()["detail"]
        assert "share.svg" in detail and "PNG" in detail


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_an_off_ratio_preview_image_is_accepted_with_a_warning() -> None:
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        event_id = await _event_id(client)
        response = await client.post(
            f"/api/admin/events/{event_id}/card-designs",
            files=[
                ("document", ("card.html", CARD, "text/html")),
                ("assets", ("paper.svg", SVG, "image/svg+xml")),
                ("og_image", ("square.png", preview_png(800, 800), "image/png")),
            ],
        )
        assert response.status_code == 201, response.text
        design = response.json()
        assert design["preview_image"]["width"] == 800
        assert len(design["warnings"]) == 1 and "cut off" in design["warnings"][0]

        await client.delete(f"/api/admin/card-designs/{design['id']}")


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_the_budget_refusal_counts_the_preview_image() -> None:
    """The budget describes what the event stores, so an upload that fits only when the
    preview image is ignored has to be refused, not accepted."""
    bulky = (
        b'<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><!-- '
        + b"x" * 400_000
        + b' --><rect width="8" height="8"/></svg>'
    )
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        event_id = await _event_id(client)
        response = await client.post(
            f"/api/admin/events/{event_id}/card-designs",
            files=[
                ("document", ("card.html", CARD, "text/html")),
                ("assets", ("paper.svg", bulky, "image/svg+xml")),
                ("og_image", ("share.png", preview_png(1200, 630, pad=250_000), "image/png")),
            ],
        )
        assert response.status_code == 413, response.text
        assert "budget" in response.json()["detail"]


@requires_db
@pytest.mark.usefixtures("app_engine_per_loop", "super_admin")
async def test_rolling_back_restores_the_previous_versions_preview_image() -> None:
    """Republishing an older version has to bring its own picture back with it, or the event
    ends up published as one card and previewed as another."""
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        event_id = await _event_id(client)
        versions = []
        for width in (1200, 1280):
            response = await client.post(
                f"/api/admin/events/{event_id}/card-designs",
                files=[
                    ("document", ("card.html", CARD, "text/html")),
                    ("assets", ("paper.svg", SVG, "image/svg+xml")),
                    ("og_image", ("share.png", preview_png(width, 630), "image/png")),
                ],
            )
            assert response.status_code == 201, response.text
            versions.append(response.json())

        assert versions[0]["preview_image"]["url"] != versions[1]["preview_image"]["url"]

        for design in versions:
            published = await client.post(f"/api/admin/card-designs/{design['id']}/publish")
            assert published.status_code == 200, published.text

        rolled_back = await client.post(f"/api/admin/card-designs/{versions[0]['id']}/publish")
        assert rolled_back.status_code == 200, rolled_back.text
        assert rolled_back.json()["preview_image"]["width"] == 1200

        listing = await client.get(f"/api/admin/events/{event_id}/card-designs")
        live = [d for d in listing.json() if d["status"] == "published"]
        assert len(live) == 1
        assert live[0]["preview_image"]["url"] == versions[0]["preview_image"]["url"]

        await client.post(f"/api/admin/card-designs/{versions[0]['id']}/unpublish")
        for design in versions:
            await client.delete(f"/api/admin/card-designs/{design['id']}")
