"""The §3 isolation controls, mechanically verified: CSP-sandbox on every /m/
response, the token boundary, the top-level-navigation refusal, upload
validation, and bridge injection (per-response, never persisted)."""

import io

from tests.conftest import as_user
from tests.test_domain import add_screen_option, make_project

HTML = b"<!doctype html><html><head><title>t</title></head><body><h1 id=\"hero\">Hi</h1><script>console.log(1)</script></body></html>"
CSS = b"body { background: #fff; }"
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 20


def upload(client, oid, name, data, content_type="application/octet-stream"):
    return client.post(f"/api/options/{oid}/files",
                       files=[("files", (name, io.BytesIO(data), content_type))])


def setup_bundle(client, kit):
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    r = upload(client, oid, "index.html", HTML)
    assert r.status_code == 200, r.text
    assert r.json()["entry_path"] == "index.html"
    r = upload(client, oid, "assets/style.css", CSS)
    assert r.status_code == 200
    return iid, oid


def mint(client, oid):
    r = client.get(f"/api/options/{oid}/view")
    assert r.status_code == 200, r.text
    return r.json()


# ------------------------------------------------------------------ uploads

def test_upload_needs_manager_side(client, kit):
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    as_user(client, "cleo")
    assert upload(client, oid, "index.html", HTML).status_code == 403


def test_upload_rejects_disallowed_types_and_traversal(client, kit):
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    r = upload(client, oid, "shell.php", b"<?php ?>")
    assert r.status_code == 400
    assert r.json()["detail"]["error_code"] == "TYPE_NOT_ALLOWED"
    for bad in ("../escape.html", "/abs.html", ".hidden/x.html", "a/../../b.html"):
        r = upload(client, oid, bad, HTML)
        assert r.status_code == 400, bad
        assert r.json()["detail"]["error_code"] == "BAD_PATH", bad


def test_reupload_replaces_in_place(client, kit):
    _iid, oid = setup_bundle(client, kit)
    r = upload(client, oid, "index.html", HTML + b"<!-- v2 -->")
    assert r.status_code == 200
    view = mint(client, oid)
    body = client.get(view["entry_url"]).content
    assert b"v2" in body


# ------------------------------------------------------------------ serving

def test_every_mockup_response_is_csp_sandboxed(client, kit):
    _iid, oid = setup_bundle(client, kit)
    view = mint(client, oid)
    for path in (view["entry_url"],
                 view["entry_url"].replace("index.html", "assets/style.css"),
                 view["entry_url"].replace("index.html", "missing.png")):
        r = client.get(path)
        assert r.headers.get("content-security-policy") == "sandbox allow-scripts", path
        assert r.headers.get("x-content-type-options") == "nosniff", path
        assert r.headers.get("referrer-policy") == "no-referrer", path


def test_entry_html_carries_bridge_with_nonce_but_disk_stays_pristine(client, kit):
    _iid, oid = setup_bundle(client, kit)
    view = mint(client, oid)
    r = client.get(view["entry_url"])
    assert r.status_code == 200
    assert "easel-bridge" in r.text
    assert view["nonce"] in r.text
    # The injection is per-response: stored bytes are untouched.
    from app.config import get_settings
    from app.db import get_session_factory
    from sqlalchemy import select
    from app.models import MockupFile
    with get_session_factory()() as db:
        row = db.scalar(select(MockupFile).where(
            MockupFile.option_id == oid, MockupFile.rel_path == "index.html"))
        stored = (get_settings().mockups_dir / str(oid) / row.stored_name).read_bytes()
    assert b"easel-bridge" not in stored


def test_subresources_serve_with_pinned_type(client, kit):
    _iid, oid = setup_bundle(client, kit)
    view = mint(client, oid)
    r = client.get(view["entry_url"].replace("index.html", "assets/style.css"))
    assert r.status_code == 200
    assert r.headers["content-type"].startswith("text/css")
    assert "easel-bridge" not in r.text  # injection is HTML-only


def test_top_level_navigation_is_refused(client, kit):
    _iid, oid = setup_bundle(client, kit)
    view = mint(client, oid)
    r = client.get(view["entry_url"], headers={"Sec-Fetch-Dest": "document"})
    assert r.status_code == 403
    assert "Easel" in r.text
    # ...and still sandboxed, so even a bypassed check executes nothing as us.
    assert r.headers.get("content-security-policy") == "sandbox allow-scripts"
    # An iframe dest (the viewer) is served.
    assert client.get(view["entry_url"],
                      headers={"Sec-Fetch-Dest": "iframe"}).status_code == 200


def test_token_boundary(client, kit):
    _iid, oid = setup_bundle(client, kit)
    view = mint(client, oid)
    # Garbage token: 403, no probe value.
    r = client.get("/m/not-a-real-token/index.html")
    assert r.status_code == 403
    # Expired token: 403.
    import app.services.mockups as m
    real = m.TOKEN_MAX_AGE
    try:
        m.TOKEN_MAX_AGE = -1
        assert client.get(view["entry_url"]).status_code == 403
    finally:
        m.TOKEN_MAX_AGE = real
    # The /m/ route needs no session (token IS the auth) — sign out and fetch.
    client.cookies.clear()
    assert client.get(view["entry_url"]).status_code == 200


def test_mint_requires_project_access(client, kit):
    _iid, oid = setup_bundle(client, kit)
    as_user(client, "nate")
    assert client.get(f"/api/options/{oid}/view").status_code == 404
    as_user(client, "cleo")
    assert client.get(f"/api/options/{oid}/view").status_code == 200


def test_bundle_with_relative_assets_serves_intact(client, kit):
    """A concept page is rarely one file: it references img/... relatively.
    The whole bundle must live in easel's storage and resolve under the same
    token prefix — the seed shipping only index.html is what left the league
    mockups image-less on first pass."""
    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    page = (b"<!doctype html><html><body>"
            b"<img src=\"img/logo.png\"><link rel=stylesheet href=\"css/a.css\">"
            b"</body></html>")
    assert upload(client, oid, "index.html", page).status_code == 200
    assert upload(client, oid, "img/logo.png", PNG).status_code == 200
    assert upload(client, oid, "css/a.css", CSS).status_code == 200

    view = mint(client, oid)
    base = view["entry_url"].rsplit("/", 1)[0]
    # Every asset resolves under the SAME token prefix the page was served
    # from, so relative references work with no cookie and no second auth.
    img = client.get(f"{base}/img/logo.png")
    assert img.status_code == 200
    assert img.headers["content-type"] == "image/png"
    assert img.headers["content-security-policy"] == "sandbox allow-scripts"
    css = client.get(f"{base}/css/a.css")
    assert css.status_code == 200
    assert css.headers["content-type"].startswith("text/css")
    # And the option reports the full bundle.
    detail = client.get(f"/api/projects/{iid}").json()
    opt = detail["screens"][0]["options"][0]
    assert opt["file_count"] == 3 and opt["has_files"] is True


def test_the_bridge_stops_the_frame_navigating_away(client, kit):
    """A concept page is full of links to pages that were never in the bundle —
    the league homepages carry 52 each. Following one navigates the frame to a
    404 and the client is left staring at a blank rectangle with no way back.

    Same-document anchors survive: they are part of the design.
    """
    from tests.test_domain import add_screen_option, make_project

    iid = make_project(client, kit)
    _sid, oid = add_screen_option(client, iid)
    token = client.get(f"/api/options/{oid}/view").json()["token"]
    served = client.get(f"/m/{token}/index.html").text

    # The guard is injected, and it lets a same-document anchor through.
    assert "blocked-nav" in served
    assert "sameDocAnchor" in served
    # It cancels navigation only — the page's own handlers still run, so a
    # takeover or reveal behaves as designed.
    assert "preventDefault" in served
    # A demo form must not post anywhere either.
    assert '"submit"' in served


def test_the_bridge_neutralises_links_that_go_nowhere(client, kit):
    """Mockups are full of `href="#"`: every nav item and service link, until
    the build is real. A real click on one scrolls the document to the TOP, so
    a demo click meant to open a menu threw the reader back to the top of the
    page mid walkthrough, and a client clicking a link did it to themselves.

    A genuine fragment is left alone, because that IS navigation inside the
    design and it should work.
    """
    from app.services import mockups

    js = mockups.inject_bridge(b"<html><body></body></html>", "nonce").decode()
    assert 'href === "#"' in js                  # the bare hash is the case
    assert "noDestination(href)" in js
    # And a demo click never navigates, whatever the href says.
    assert "demoClick" in js
    assert "var dismissTimer = 0, picking = false, demoClick = false;" in js


def test_the_bridge_forwards_only_a_real_escape(client, kit):
    """The frame forwards Escape so the app's handlers hear it (v0.36.3). It
    also DISPATCHES a synthetic Escape to close the design's own menus after a
    demo click, and forwarding that told the app the reader had pressed
    Escape, ending the walkthrough a few seconds into the beat.
    """
    from app.services import mockups

    js = mockups.inject_bridge(b"<html><body></body></html>", "nonce").decode()
    assert 'e.key === "Escape" && e.isTrusted' in js
