"""The public website (app/routers/public_site.py): site settings, putting a
page on the site, what a public page may hold, how pages are served to
visitors and search engines, HTML pages and the .zip site import."""

import io
import json
import uuid
import zipfile

import pytest

from app.core.html_sanitize import SanitizeReport, sanitize_document, sanitize_fragment
from app.crud import public_site as site_crud
from tests.test_pages import _create_role_and_token

FAKE_INDEX = '<!doctype html><html lang="en"><head><meta charset="UTF-8"><title>FlowFabric</title><script type="module" src="/assets/app.js"></script></head><body><div id="root"></div></body></html>'


@pytest.fixture(autouse=True)
def _fake_app_index(monkeypatch):
    async def fake() -> str:
        return FAKE_INDEX

    monkeypatch.setattr(site_crud, "app_index_html", fake)


def _slug() -> str:
    return f"site-{uuid.uuid4().hex[:8]}"


async def _site(client, headers, **extra) -> str:
    slug = _slug()
    resp = await client.put("/tenants/me/site", headers=headers, json={"enabled": True, "slug": slug, "site_name": "Acme Transport", **extra})
    assert resp.status_code == 200, resp.text
    return slug


async def _public_page(client, headers, name: str, blocks: list, *, seo: dict | None = None, publish: bool = True) -> dict:
    resp = await client.post("/pages", headers=headers, json={"name": name, "blocks": blocks, **({"seo": seo} if seo else {})})
    assert resp.status_code == 201, resp.text
    page = resp.json()
    resp = await client.patch(f"/pages/{page['id']}/site", headers=headers, json={"is_public": True})
    assert resp.status_code == 200, resp.text
    if publish:
        resp = await client.post(f"/pages/{page['id']}/publish", headers=headers, json={})
        assert resp.status_code == 200, resp.text
    return resp.json()


WELCOME = [
    {"id": "h", "type": "page_header", "label": "Welcome to Acme", "subtitle": "Buses across Kerala", "layout": {"x": 0, "y": 0, "w": 800, "h": 80}},
    {"id": "p", "type": "text", "text": "We run three hundred routes every day, from Kasaragod to Thiruvananthapuram.", "layout": {"x": 0, "y": 100, "w": 800, "h": 60}},
    {"id": "img", "type": "image", "image_url": "https://cdn.example.com/bus.jpg", "alt_text": "A red bus", "layout": {"x": 0, "y": 180, "w": 400, "h": 200}},
]


# ---- sanitizing ---------------------------------------------------------------------------------------------


def test_sanitize_document_removes_everything_that_runs():
    report = SanitizeReport()
    out = sanitize_document(
        """<html><head><script>steal()</script><meta http-equiv="refresh" content="0;url=https://evil"><style>h1{color:red}</style>
        <link rel="stylesheet" href="https://cdn.example.com/site.css"></head>
        <body onload="x()"><h1 onclick="y()">Hi</h1><a href="javascript:alert(1)">a</a><a href="/about">b</a>
        <iframe src="https://www.youtube.com/embed/abc"></iframe><iframe src="https://evil.example.com"></iframe>
        <svg><a xlink:href="javascript:alert(1)"><text>t</text></a><animate attributeName="href" values="javascript:alert(1)"/></svg>
        <img src="data:image/png;base64,AAAA"><object data="x.swf"></object></body></html>""",
        report,
    )
    lowered = out.lower()
    for bad in ("<script", "onload", "onclick", "javascript:", "http-equiv", "evil.example.com", "<animate", "<object"):
        assert bad not in lowered, bad
    for kept in ("h1{color:red}", "https://cdn.example.com/site.css", 'href="/about"', "youtube.com/embed/abc", "data:image/png"):
        assert kept in out, kept
    assert any("script" in m for m in report.messages())


def test_sanitize_fragment_keeps_formatting():
    assert sanitize_fragment('<p class="x" onclick="y()">Hi <b>there</b><script>z()</script></p>') == '<p class="x">Hi <b>there</b></p>'


# ---- site settings ------------------------------------------------------------------------------------------


async def test_site_settings_round_trip_and_rules(client, auth_headers):
    resp = await client.put("/tenants/me/site", headers=auth_headers, json={"enabled": True})
    assert resp.status_code == 422

    resp = await client.put("/tenants/me/site", headers=auth_headers, json={"enabled": True, "slug": "Not A Slug"})
    assert resp.status_code == 422

    slug = _slug()
    resp = await client.put(
        "/tenants/me/site",
        headers=auth_headers,
        json={
            "enabled": True,
            "slug": slug,
            "domains": ["https://WWW.Acme-Example.test/", "www.acme-example.test"],
            "google_site_verification": '<meta name="google-site-verification" content="abc123XYZ" />',
        },
    )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["slug"] == slug and body["path"] == f"/site/{slug}/"
    assert body["domains"] == ["www.acme-example.test"]
    assert body["google_site_verification"] == "abc123XYZ"

    page = (await client.post("/pages", headers=auth_headers, json={"name": "Private"})).json()
    resp = await client.put("/tenants/me/site", headers=auth_headers, json={"enabled": True, "slug": slug, "home_page_id": page["id"]})
    assert resp.status_code == 422
    assert "isn't public" in resp.json()["detail"]


async def test_site_slug_is_unique_across_workspaces(client, auth_headers, test_tenant):
    from app.models.identity import Tenant
    from tests.conftest import AsyncSessionLocal

    slug = _slug()
    async with AsyncSessionLocal() as db, db.begin():
        other = Tenant(name="Pytest other site", status="active", site_slug=slug, site_settings={"enabled": True})
        db.add(other)
        await db.flush()
        other_id = other.id
    try:
        resp = await client.put("/tenants/me/site", headers=auth_headers, json={"enabled": True, "slug": slug})
        assert resp.status_code == 409
    finally:
        async with AsyncSessionLocal() as db, db.begin():
            await db.execute(Tenant.__table__.delete().where(Tenant.id == other_id))


# ---- putting pages on the site ------------------------------------------------------------------------------


async def test_page_with_data_blocks_cannot_go_public(client, auth_headers):
    resp = await client.post(
        "/pages",
        headers=auth_headers,
        json={"name": "Data", "blocks": [{"id": "l", "type": "data_list", "data_source": f"master:{uuid.uuid4()}"}]},
    )
    page = resp.json()
    resp = await client.patch(f"/pages/{page['id']}/site", headers=auth_headers, json={"is_public": True})
    assert resp.status_code == 422
    assert resp.json()["detail"]["code"] == "not_public_safe"

    # A button that starts a workflow needs a signed-in person too.
    resp = await client.patch(
        f"/pages/{page['id']}",
        headers=auth_headers,
        json={"blocks": [{"id": "b", "type": "button", "label": "Apply", "action": {"type": "trigger_workflow", "workflow_id": str(uuid.uuid4())}}]},
    )
    assert resp.status_code == 200
    resp = await client.patch(f"/pages/{page['id']}/site", headers=auth_headers, json={"is_public": True})
    assert resp.status_code == 422
    assert "Apply" in resp.json()["detail"]["message"]


async def test_publishing_a_public_page_rechecks_its_blocks(client, auth_headers):
    await _site(client, auth_headers)
    page = await _public_page(client, auth_headers, "About us", WELCOME)
    assert page["is_public"] is True and page["slug"] == "about-us"
    await client.patch(f"/pages/{page['id']}", headers=auth_headers, json={"blocks": [{"id": "f", "type": "form", "form_id": str(uuid.uuid4())}]})
    resp = await client.post(f"/pages/{page['id']}/publish", headers=auth_headers, json={})
    assert resp.status_code == 422


async def test_page_slug_clash(client, auth_headers):
    a = (await client.post("/pages", headers=auth_headers, json={"name": "Contact"})).json()
    b = (await client.post("/pages", headers=auth_headers, json={"name": "Contact"})).json()
    first = await client.patch(f"/pages/{a['id']}/site", headers=auth_headers, json={"is_public": True})
    assert first.json()["slug"] == "contact"
    # Picked automatically: the next free one.
    second = await client.patch(f"/pages/{b['id']}/site", headers=auth_headers, json={"is_public": True})
    assert second.json()["slug"] == "contact-2"
    # Asked for by name: refused.
    clash = await client.patch(f"/pages/{b['id']}/site", headers=auth_headers, json={"is_public": True, "slug": "contact"})
    assert clash.status_code == 409


async def test_putting_a_page_on_the_site_needs_publish_permission(client, test_tenant):
    token = await _create_role_and_token(test_tenant["tenant_id"], {"pages": ["view", "create", "edit"]})
    headers = {"Authorization": f"Bearer {token}"}
    page = (await client.post("/pages", headers=headers, json={"name": "Mine"})).json()
    resp = await client.patch(f"/pages/{page['id']}/site", headers=headers, json={"is_public": True})
    assert resp.status_code == 403


# ---- serving blocks pages -----------------------------------------------------------------------------------


async def test_blocks_page_is_served_with_seo_and_readable_content(client, auth_headers):
    slug = await _site(client, auth_headers, default_og_image="https://cdn.example.com/card.png")
    page = await _public_page(
        client, auth_headers, "Routes", WELCOME,
        seo={"title": "Bus routes", "keywords": "bus, kerala", "json_ld": json.dumps({"@type": "Thing", "name": "</script><script>x()</script>"})},
    )

    resp = await client.get(f"/site/{slug}/{page['slug']}")
    assert resp.status_code == 200, resp.text
    body = resp.text
    assert "<title>Bus routes | Acme Transport</title>" in body
    # Blank description: the start of the page's own text.
    assert '<meta name="description" content="Welcome to Acme Buses across Kerala' in body
    assert f'<link rel="canonical" href="http://test/site/{slug}/{page["slug"]}">' in body
    assert '<meta property="og:image" content="https://cdn.example.com/card.png">' in body
    assert '<meta name="robots" content="index,follow">' in body
    # The web app's own scripts start, inside the shell, with the page's content for crawlers.
    assert '<script type="module" src="/assets/app.js"></script>' in body
    assert f'data-ff-site="{slug}"' in body
    assert "<h1>Welcome to Acme</h1>" in body and 'alt="A red bus"' in body
    # Structured data can't close its own <script>.
    assert "</script><script>x()" not in body and "\\u003c/script\\u003e" in body
    assert body.count("<title>") == 1


async def test_unpublished_and_private_pages_are_not_served(client, auth_headers):
    slug = await _site(client, auth_headers)
    draft = await _public_page(client, auth_headers, "Draft", WELCOME, publish=False)
    assert (await client.get(f"/site/{slug}/{draft['slug']}")).status_code == 404

    page = await _public_page(client, auth_headers, "Was public", WELCOME)
    await client.patch(f"/pages/{page['id']}/site", headers=auth_headers, json={"is_public": False})
    assert (await client.get(f"/site/{slug}/{page['slug']}")).status_code == 404

    # A switched-off site serves nothing.
    live = await _public_page(client, auth_headers, "Live", WELCOME)
    await client.put("/tenants/me/site", headers=auth_headers, json={"enabled": False, "slug": slug})
    assert (await client.get(f"/site/{slug}/{live['slug']}")).status_code == 404


async def test_landing_page_root_sitemap_and_robots(client, auth_headers):
    host = f"{uuid.uuid4().hex[:8]}.acme-example.test"
    home = await _public_page(client, auth_headers, "Home", WELCOME)
    about = await _public_page(client, auth_headers, "About", WELCOME, seo={"robots_index": False})
    slug = _slug()
    resp = await client.put("/tenants/me/site", headers=auth_headers, json={"enabled": True, "slug": slug, "site_name": "Acme", "home_page_id": home["id"], "domains": [host]})
    assert resp.status_code == 200 and resp.json()["home_page_live"] is True

    landing = await client.get(f"/site/{slug}/")
    assert landing.status_code == 200
    assert "<title>Acme</title>" in landing.text
    assert '"@type": "WebSite"' in landing.text

    # "/" on the site's own domain is the landing page; elsewhere, the web app.
    root = await client.get("/site/_root", headers={"host": host})
    assert root.status_code == 200 and "<title>Acme</title>" in root.text
    other = await client.get("/site/_root", headers={"host": "nobody.example.test"})
    assert other.text == FAKE_INDEX
    assert (await client.get("/public/site-root", params={"host": host})).json() == {"slug": slug}

    sitemap = await client.get(f"/site/{slug}/sitemap.xml")
    assert sitemap.status_code == 200
    assert f"<loc>http://test/site/{slug}/</loc>" in sitemap.text
    assert "/about<" not in sitemap.text  # noindex pages stay out

    robots = await client.get("/site/_root/robots.txt", headers={"host": host})
    assert f"Sitemap: http://{host}/site/{slug}/sitemap.xml" in robots.text
    assert "Disallow: /api/" in robots.text
    assert about["slug"] == "about"


async def test_public_page_json_for_the_web_app(client, auth_headers):
    slug = await _site(client, auth_headers)
    target = await _public_page(client, auth_headers, "Fares", WELCOME)
    blocks = [{"id": "go", "type": "button", "label": "See fares", "action": {"type": "navigate", "target_type": "page", "target_id": target["id"]}}]
    page = await _public_page(client, auth_headers, "Start", blocks)

    resp = await client.get(f"/public/sites/{slug}/page", params={"path": page["slug"]})
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body["blocks"][0]["label"] == "See fares"
    assert body["links"][target["id"]] == f"/site/{slug}/fares"
    assert body["site"]["slug"] == slug
    # The server-rendered copy links there too.
    html = (await client.get(f"/site/{slug}/{page['slug']}")).text
    assert f'<a href="/site/{slug}/fares">See fares</a>' in html

    assert (await client.get(f"/public/sites/{slug}/page", params={"path": "nope"})).status_code == 404


# ---- html pages ---------------------------------------------------------------------------------------------


async def test_html_page_is_sanitized_and_served_exactly(client, auth_headers):
    slug = await _site(client, auth_headers)
    page = (await client.post("/pages", headers=auth_headers, json={"name": "Landing", "content_mode": "html"})).json()
    doc = (
        "<html><head><title>Old title</title><meta name='description' content='old'><style>.hero{background:#123}</style>"
        "<script>steal(localStorage)</script></head><body><section class='hero'><h1>Fly with us</h1>"
        "<img src='/site/x.png' onerror='steal()'></section></body></html>"
    )
    resp = await client.patch(f"/pages/{page['id']}", headers=auth_headers, json={"html_document": doc, "seo": {"title": "Fly", "description": "Cheap flights"}})
    assert resp.status_code == 200, resp.text
    saved = resp.json()["html_document"]
    assert "<script" not in saved and "onerror" not in saved and ".hero{background:#123}" in saved

    await client.patch(f"/pages/{page['id']}/site", headers=auth_headers, json={"is_public": True})
    await client.post(f"/pages/{page['id']}/publish", headers=auth_headers, json={})

    served = await client.get(f"/site/{slug}/landing")
    assert served.status_code == 200
    assert served.headers["content-security-policy"].startswith("script-src 'none'")
    text = served.text
    assert "<title>Fly | Acme Transport</title>" in text and "Old title" not in text
    assert '<meta name="description" content="Cheap flights">' in text and "content='old'" not in text and 'content="old"' not in text
    assert '<section class="hero"><h1>Fly with us</h1>' in text
    assert "/assets/app.js" not in text  # html pages are served whole, without the web app


async def test_html_document_needs_the_html_permission(client, test_tenant):
    token = await _create_role_and_token(test_tenant["tenant_id"], {"pages": ["view", "create", "edit"]})
    headers = {"Authorization": f"Bearer {token}"}
    page = (await client.post("/pages", headers=headers, json={"name": "Mine", "content_mode": "html"})).json()
    resp = await client.patch(f"/pages/{page['id']}", headers=headers, json={"html_document": "<h1>Hi</h1>"})
    assert resp.status_code == 403


def _site_zip() -> bytes:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        zf.writestr(
            "mysite/index.html",
            "<html><head><title>Acme Home</title><link rel='stylesheet' href='css/site.css'><script src='js/app.js'></script></head>"
            "<body><img src='img/logo.png' alt='Logo'><img data-src='img/logo.png' src='blank.gif'><a href='about.html#team'>About</a>"
            "<img src='img/missing.png'></body></html>",
        )
        zf.writestr("mysite/about.html", "<html><head><title>About Acme</title></head><body><h1>About</h1><a href='index.html'>Home</a></body></html>")
        zf.writestr("mysite/css/site.css", "@import 'base.css'; .hero{background:url('../img/logo.png') no-repeat}")
        zf.writestr("mysite/css/base.css", "body{margin:0}")
        zf.writestr("mysite/img/logo.png", b"\x89PNG\r\n\x1a\nfake")
        zf.writestr("mysite/js/app.js", "alert(1)")
        zf.writestr("__MACOSX/._index.html", "junk")
    return buf.getvalue()


async def test_zip_site_import_makes_exact_pages_with_assets(client, auth_headers, test_tenant):
    resp = await client.post("/pages/import-html", headers=auth_headers, files={"file": ("mysite.zip", _site_zip(), "application/zip")})
    assert resp.status_code == 201, resp.text
    result = resp.json()
    assert [p["name"] for p in result["pages"]] == ["Acme Home", "About Acme"]
    assert result["assets"] == 1
    assert any("JavaScript" in n for n in result["notes"])
    assert any("missing.png" in n for n in result["notes"])

    home_id, about_id = result["pages"][0]["id"], result["pages"][1]["id"]
    home = (await client.get(f"/pages/{home_id}", headers=auth_headers)).json()
    assert home["content_mode"] == "html" and home["slug"] == "home"
    doc = home["html_document"]
    assert "<script" not in doc
    assert "body{margin:0}" in doc  # the @import, written in
    assert f"/site/_files/{test_tenant['tenant_id']}/" in doc
    assert "../img/logo.png" not in doc and "blank.gif" not in doc
    assert f"ff-page:{about_id}#team" in doc

    # The image is public; the link between pages resolves once served.
    file_url = doc.split('src="')[1].split('"')[0]
    image = await client.get(file_url)
    assert image.status_code == 200 and image.headers["content-type"] == "image/png"
    assert "sandbox" in image.headers["content-security-policy"]

    slug = await _site(client, auth_headers)
    for pid in (home_id, about_id):
        await client.patch(f"/pages/{pid}/site", headers=auth_headers, json={"is_public": True})
        await client.post(f"/pages/{pid}/publish", headers=auth_headers, json={})
    served = (await client.get(f"/site/{slug}/home")).text
    assert f'href="/site/{slug}/about#team"' in served


async def test_import_into_an_existing_page(client, auth_headers):
    page = (await client.post("/pages", headers=auth_headers, json={"name": "Keep my name"})).json()
    resp = await client.post("/pages/import-html", headers=auth_headers, data={"html": "<h1>Pasted</h1>", "page_id": page["id"]})
    assert resp.status_code == 201, resp.text
    assert resp.json()["pages"] == [{"id": page["id"], "name": "Keep my name", "slug": None}]
    after = (await client.get(f"/pages/{page['id']}", headers=auth_headers)).json()
    assert after["content_mode"] == "html" and "<h1>Pasted</h1>" in after["html_document"]


async def test_public_assets(client, auth_headers, test_tenant):
    resp = await client.post("/pages/assets", headers=auth_headers, files={"file": ("hero.png", b"\x89PNG\r\n\x1a\n", "image/png")})
    assert resp.status_code == 201, resp.text
    asset = resp.json()
    assert asset["url"].startswith(f"/site/_files/{test_tenant['tenant_id']}/{asset['id']}/")
    assert (await client.get(asset["url"])).status_code == 200

    refused = await client.post("/pages/assets", headers=auth_headers, files={"file": ("x.html", b"<script>", "text/html")})
    assert refused.status_code == 415

    # An ordinary (private) upload is never served here.
    private = (await client.post("/uploads", headers=auth_headers, files={"file": ("secret.png", b"\x89PNG", "image/png")})).json()
    assert (await client.get(f"/site/_files/{test_tenant['tenant_id']}/{private['id']}/secret.png")).status_code == 404
