#!/usr/bin/env python3
"""HTTP integration checks for Housa CMS.

Run against the supervised development server, never a production site:

    CMS_TEST_URL=http://127.0.0.1:4173 python tests/integration.py

On a fresh database, run bin/setup.php first. This runner reads storage/setup.key
and creates a temporary test administrator. On an initialized database, provide
CMS_TEST_EMAIL and CMS_TEST_PASSWORD for an existing administrator. Temporary
records are deleted and the homepage is restored. The first administrator is
retained because deleting the last administrator is intentionally prohibited;
reset a fresh test database before packaging or creating the real administrator.

No external Python dependencies are required. No credentials are printed.
"""

from __future__ import annotations

import argparse
import base64
import copy
import html
import http.cookiejar
import json
import os
from pathlib import Path
import secrets
import sys
import time
from typing import Any, Callable
import urllib.error
import urllib.parse
import urllib.request


ROOT = Path(__file__).resolve().parents[1]


class Response:
    def __init__(self, status: int, body: bytes, headers: Any):
        self.status = status
        self.body = body
        self.headers = headers
        try:
            self.json = json.loads(body)
        except (ValueError, UnicodeDecodeError):
            self.json = None

    @property
    def data(self) -> Any:
        if isinstance(self.json, dict) and self.json.get("ok") is True:
            return self.json.get("data")
        return self.json

    @property
    def text(self) -> str:
        return self.body.decode("utf-8", errors="replace")


class Client:
    def __init__(self, base_url: str):
        self.base_url = base_url.rstrip("/")
        self.cookies = http.cookiejar.CookieJar()
        self.opener = urllib.request.build_opener(
            urllib.request.ProxyHandler({}),
            urllib.request.HTTPCookieProcessor(self.cookies),
        )
        self.csrf = ""

    def request(
        self,
        method: str,
        path: str,
        payload: Any = None,
        *,
        csrf: bool = True,
        raw: bytes | None = None,
        content_type: str | None = None,
    ) -> Response:
        headers = {"Accept": "application/json"}
        if csrf and method not in ("GET", "HEAD") and self.csrf:
            headers["X-CSRF-Token"] = self.csrf
        body = raw
        if payload is not None:
            body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
            headers["Content-Type"] = "application/json"
        if content_type:
            headers["Content-Type"] = content_type
        req = urllib.request.Request(
            self.base_url + path, data=body, headers=headers, method=method
        )
        try:
            with self.opener.open(req, timeout=45) as result:
                response = Response(result.status, result.read(), result.headers)
        except urllib.error.HTTPError as error:
            response = Response(error.code, error.read(), error.headers)
        data = response.data
        if isinstance(data, dict) and isinstance(data.get("csrf_token"), str):
            self.csrf = data["csrf_token"]
        return response

    def upload(self, filename: str, mime: str, body: bytes) -> Response:
        boundary = "HousaTest" + secrets.token_hex(12)
        data = (
            f"--{boundary}\r\n"
            f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
            f"Content-Type: {mime}\r\n\r\n"
        ).encode() + body + f"\r\n--{boundary}--\r\n".encode()
        return self.request(
            "POST", "/api/media", raw=data,
            content_type=f"multipart/form-data; boundary={boundary}",
        )


class Suite:
    def __init__(self, base_url: str, email: str, password: str, setup_key: str):
        self.base_url = base_url
        self.admin = Client(base_url)
        self.public = Client(base_url)
        self.email = email
        self.password = password
        self.setup_key = setup_key
        self.marker = "CMS_TEST_" + secrets.token_hex(6)
        self.passed = 0
        self.failed = 0
        self.skipped = 0
        self.cleanups: list[tuple[str, Callable[[], None]]] = []
        self.created_initial_admin = False

    def check(self, condition: bool, description: str) -> None:
        if not condition:
            raise AssertionError(description)
        self.passed += 1
        print("  PASS", description, flush=True)

    def skip(self, description: str) -> None:
        self.skipped += 1
        print("  SKIP", description, flush=True)

    @staticmethod
    def successful(response: Response, label: str) -> Any:
        if not 200 <= response.status < 300:
            detail = response.json.get("error", "") if isinstance(response.json, dict) else ""
            raise AssertionError(f"{label}: expected success, received HTTP {response.status}: {detail}")
        if isinstance(response.json, dict) and response.json.get("ok") is False:
            raise AssertionError(f"{label}: API reported failure")
        return response.data

    def denied(self, response: Response, label: str) -> None:
        self.check(400 <= response.status < 500, label + " is rejected")

    def group(self, name: str, callback: Callable[[], None]) -> None:
        print("\n" + name, flush=True)
        try:
            callback()
        except Exception as error:
            self.failed += 1
            print(f"  FAIL {type(error).__name__}: {error}", flush=True)

    def authenticate(self) -> None:
        anon = self.successful(self.public.request("GET", "/api/session"), "Public session")
        self.check(not anon["authenticated"], "Anonymous session has no authenticated user")
        self.check(bool(anon.get("csrf_token")), "Session supplies a CSRF token")
        self.check("setup_key" not in anon, "Setup secret is absent from public session response")
        for path in ("/api/bootstrap", "/api/pages", "/api/users", "/api/backup"):
            self.denied(self.public.request("GET", path), "Anonymous access to " + path)

        state = self.successful(self.admin.request("GET", "/api/session"), "Admin session")
        if state.get("setup_required"):
            if not self.setup_key:
                key_file = ROOT / "storage/setup.key"
                if key_file.exists():
                    self.setup_key = key_file.read_text().strip()
            if not self.setup_key:
                raise RuntimeError("Fresh database: run bin/setup.php first or supply CMS_TEST_SETUP_KEY.")
            self.email = self.email or self.marker.lower() + "@example.invalid"
            self.password = self.password or "Test!" + secrets.token_hex(18)
            payload = {
                "setup_key": self.setup_key, "name": "CMS Integration Test",
                "email": self.email, "password": self.password,
            }
            self.denied(self.admin.request("POST", "/api/setup", payload, csrf=False), "Setup without CSRF")
            result = self.successful(self.admin.request("POST", "/api/setup", payload), "Initial setup")
            self.created_initial_admin = True
            self.check(result["authenticated"], "Setup creates an authenticated administrator")
            self.denied(self.public.request("POST", "/api/setup", payload), "Setup after administrator exists")
        else:
            if not self.email or not self.password:
                raise RuntimeError("Initialized database: provide CMS_TEST_EMAIL and CMS_TEST_PASSWORD.")
            credentials = {"email": self.email, "password": self.password}
            self.denied(self.admin.request("POST", "/api/login", credentials, csrf=False), "Login without CSRF")
            state = self.successful(self.admin.request("POST", "/api/login", credentials), "Admin login")
            self.check(state["authenticated"], "Correct credentials authenticate")

        # A separate session verifies ordinary login even on freshly initialized data.
        other = Client(self.base_url)
        self.successful(other.request("GET", "/api/session"), "Secondary session")
        self.denied(other.request("POST", "/api/login", {
            "email": self.email, "password": "incorrect-" + secrets.token_hex(8),
        }), "Wrong password")
        state = self.successful(other.request("POST", "/api/login", {
            "email": self.email, "password": self.password,
        }), "Secondary login")
        self.check(state["authenticated"], "Login persists in an independent session")
        self.successful(other.request("POST", "/api/logout", {}), "Logout")
        self.denied(other.request("GET", "/api/bootstrap"), "Access after logout")

    def bootstrap_and_csrf(self) -> None:
        data = self.successful(self.admin.request("GET", "/api/bootstrap"), "Bootstrap")
        self.check(data["user"]["role"] == "admin", "Administrator role is available")
        self.check(len(data["pages"]) >= 30, "All 30 template pages have been imported")
        self.check(all(key in data["stats"] for key in ("pages", "media", "inquiries")), "Dashboard statistics are backed by API data")
        self.denied(self.admin.request("PUT", "/api/settings", {"settings": {"site_name": self.marker}}, csrf=False), "Settings write without CSRF")
        self.denied(self.admin.request("PUT", "/api/settings", {"settings": {"primary_color": "red;display:none"}}), "Invalid theme color")
        self.denied(self.admin.request("PUT", "/api/settings", {"settings": {"logo": "javascript:alert(1)"}}), "Executable logo URL")
        self.denied(self.admin.request("PUT", "/api/settings", {"settings": {"custom_css": "@import url(https://example.invalid/a.css);"}}), "External CSS import")

    def appearance(self) -> None:
        self.check(self.public.request("GET", "/api/appearance").status == 401, "Anonymous appearance metadata returns HTTP 401")
        self.check(self.public.request("POST", "/api/appearance/preview", {"page_slug": "index", "mode": "styles"}).status == 401, "Anonymous appearance preview returns HTTP 401")
        self.check(self.admin.request("POST", "/api/appearance/preview", {"page_slug": "index"}, csrf=False).status == 419, "Appearance preview without CSRF returns HTTP 419")
        info = self.successful(self.admin.request("GET", "/api/appearance"), "Appearance workspace metadata")
        self.check(isinstance(info["settings"], dict) and isinstance(info["pages"], list), "Appearance returns object settings and a page list")
        self.check(not any(page["protected"] for page in info["pages"]), "Appearance selector excludes protected demo pages")
        self.check(info["schema"]["breakpoints"] == {"tablet": 991, "mobile": 600}, "HTTP design schema describes the supported responsive sizes")
        self.check(len(info["schema"]["fields"]) == 40, "HTTP design schema includes all 40 controls")
        original_appearance = copy.deepcopy(info["settings"].get("appearance", {}))

        def restore_appearance() -> None:
            self.successful(self.admin.request("PUT", "/api/settings", {
                "settings": {"appearance": original_appearance},
            }), "Restore original appearance")

        self.cleanups.append(("Restore original appearance", restore_appearance))
        global_style = {
            "header": {"background_mode": "solid", "background_color": "#13579B", "mobile": {"logo_width": 98}},
            "buttons": {"border_radius": 0, "button_background": "#245678", "button_text": "#FFFFFF"},
        }
        saved_settings = self.successful(self.admin.request("PUT", "/api/settings", {
            "settings": {"appearance": global_style},
        }), "Save global appearance")
        self.check(saved_settings["appearance"] == global_style, "Global appearance persists through the HTTP settings API")
        reloaded_settings = self.successful(self.admin.request("GET", "/api/settings"), "Reload global appearance")
        self.check(reloaded_settings["appearance"] == global_style, "Global style changes survive a separate HTTP read")
        self.check(all(reloaded_settings[key] == value for key, value in info["settings"].items() if key != "appearance"), "Global design writes preserve existing branding and content settings")

        slug = self.marker.lower().replace("_", "-") + "-design"
        page = self.successful(self.admin.request("POST", "/api/pages", {
            "slug": slug, "title": "Temporary design test", "template": "index",
        }), "Create isolated design page")
        endpoint = "/api/pages/" + slug

        def delete_page() -> None:
            current = self.successful(self.admin.request("GET", endpoint), "Read design page for cleanup")
            self.successful(self.admin.request("DELETE", endpoint, {"version": current["version"]}), "Delete design page")

        self.cleanups.append(("Delete temporary design page", delete_page))
        hero = next(section["id"] for section in page["sections"] if section["id"] != "global")
        content = copy.deepcopy(page["content"])
        content["section_styles"] = {
            hero: {"background_mode": "solid", "background_color": "#6B2C91", "padding_top": 0, "mobile": {"hidden": False, "padding_x": 12}},
        }
        content["region_styles"] = {"footer": {"text_color": "#663344"}}
        content["seo_title"] = self.marker + " design SEO"
        draft = self.successful(self.admin.request("PUT", endpoint, {
            "title": page["title"], "content": content, "version": page["version"],
        }), "Save design draft")
        self.check(draft["content"] == content, "Section and region styles persist alongside the full page content")
        self.check(draft["content"]["section_styles"][hero]["padding_top"] == 0 and draft["content"]["section_styles"][hero]["mobile"]["hidden"] is False, "JSON style values preserve zero and false")
        self.check(self.public.request("GET", "/index.php?page=" + slug).status == 404, "Unpublished design page is unavailable publicly")
        stale = self.admin.request("PUT", endpoint, {"content": content, "version": page["version"]})
        self.check(stale.status == 409, "Stale design writes return HTTP 409")
        self.denied(self.admin.request("PUT", "/api/settings", {"settings": {"appearance": {"header": {"text_color": "red;display:none"}}}}), "Injected nested design color")
        malformed = copy.deepcopy(content)
        malformed["section_styles"][hero]["mobile"]["tablet"] = {"hidden": True}
        self.denied(self.admin.request("PUT", endpoint, {"content": malformed, "version": draft["version"]}), "Nested responsive breakpoint")
        self.denied(self.admin.request("POST", "/api/appearance/preview", {"page_slug": "../index", "mode": "styles"}), "Arbitrary appearance template path")
        self.denied(self.admin.request("POST", "/api/appearance/preview", {"page_slug": slug, "html": "<script>alert(1)</script>"}), "Arbitrary preview HTML")

        before_preview = self.successful(self.admin.request("GET", "/api/backup"), "Snapshot before appearance previews")
        before_activity = self.successful(self.admin.request("GET", "/api/activity"), "Activity before appearance previews")
        styles = self.successful(self.admin.request("POST", "/api/appearance/preview", {
            "page_slug": slug, "mode": "styles",
        }), "Preview saved draft styles")
        self.check(isinstance(styles["css"], str) and "html" not in styles and "#6B2C91" in styles["css"], "Styles preview compiles the saved draft without sending a document")
        self.check(styles["settings"] == reloaded_settings and styles["section_order"] == content["section_order"] and styles["hidden_sections"] == content["hidden_sections"], "Styles preview returns complete settings and page layout state")
        unsaved = copy.deepcopy(content)
        unsaved["section_styles"][hero]["background_color"] = "#8F5AA0"
        document = self.successful(self.admin.request("POST", "/api/appearance/preview", {
            "page_slug": slug, "mode": "document", "content": unsaved,
            "settings": {"site_name": "Unsaved preview name"},
        }), "Preview unsaved design document")
        self.check(isinstance(document["html"], str) and 'id="cms-design-style"' in document["html"] and "cms-designer.js" in document["html"], "Document preview returns working designer HTML")
        self.check("#8F5AA0" in document["css"] and document["settings"]["site_name"] == "Unsaved preview name" and document["settings"]["appearance"] == global_style, "Document preview merges unsaved settings and section overrides")
        self.check(isinstance(document["settings"], dict) and isinstance(document["section_order"], list) and isinstance(document["hidden_sections"], list), "Preview JSON retains object and list types expected by the editor")
        after_preview = self.successful(self.admin.request("GET", "/api/backup"), "Snapshot after appearance previews")
        self.check(all(after_preview[key] == before_preview[key] for key in ("settings", "pages", "collections")), "Unsaved HTTP previews leave stored content and versions unchanged")
        self.check(self.successful(self.admin.request("GET", "/api/activity"), "Activity after preview") == before_activity, "Unsaved previews do not add audit activity")

        published = self.successful(self.admin.request("POST", endpoint + "/publish", {"version": draft["version"]}), "Publish saved design")
        public_html = self.successful_public("/index.php?page=" + slug)
        self.check("#6B2C91" in public_html and "#8F5AA0" not in public_html, "Publication renders the saved draft instead of unsaved preview changes")
        self.check("cms-designer.js" not in public_html and content["seo_title"] in public_html, "Public pages include saved SEO and exclude designer behavior")
        self.check(published["published_content"]["section_styles"] == content["section_styles"], "Published style data is stored in the published snapshot")
        revised = self.successful(self.admin.request("PUT", endpoint, {
            "content": unsaved, "version": published["version"],
        }), "Save second design draft")
        self.check("#8F5AA0" not in self.successful_public("/index.php?page=" + slug), "Later design drafts leave the published appearance unchanged")
        reverted = self.successful(self.admin.request("POST", endpoint + "/revert", {"version": revised["version"]}), "Revert design draft")
        self.check(reverted["content"] == content, "HTTP revert restores published page design and content together")

        # This group runs before collection CRUD; a valid restore can replace item
        # IDs without invalidating cleanup handles held by later test groups.
        styled_backup = self.successful(self.admin.request("GET", "/api/backup"), "Export design backup")
        backup_page = next(row for row in styled_backup["pages"] if row["slug"] == slug)
        self.check(styled_backup["settings"]["appearance"] == global_style and backup_page["content"]["section_styles"] == content["section_styles"], "HTTP backups include global and section design")
        self.successful(self.admin.request("POST", "/api/backup/restore", {"backup": styled_backup}), "Restore valid design backup")
        restored = self.successful(self.admin.request("GET", endpoint), "Read restored design page")
        self.check(restored["content"] == content and restored["published_content"] == content, "Valid HTTP restore preserves page design states")
        self.check(self.successful(self.admin.request("GET", "/api/settings"), "Read restored appearance")["appearance"] == global_style, "Valid HTTP restore preserves global design")
        inherited = copy.deepcopy(content)
        inherited["section_styles"] = {hero: {"background_color": "", "mobile": {}}}
        inherited["region_styles"] = {}
        reset = self.successful(self.admin.request("PUT", endpoint, {"content": inherited, "version": restored["version"]}), "Reset draft design to inherit")
        self.check(reset["content"]["section_styles"] == {} and reset["content"]["region_styles"] == {}, "Empty section and region maps serialize as JSON objects")
        reset_settings = self.successful(self.admin.request("PUT", "/api/settings", {"settings": {"appearance": {}}}), "Reset global design to inherit")
        self.check(reset_settings["appearance"] == {}, "Empty global appearance serializes as a JSON object")

    def collections(self) -> None:
        collection = "/api/collections/properties"
        item = {
            "title": self.marker, "slug": self.marker.lower().replace("_", "-"), "price": 125000,
            "price_suffix": "", "address": "Test address", "type": "Apartment",
            "bedrooms": 2, "bathrooms": 1, "area": 85,
            "image": "/assets/img/all-images/properties/property-img1.png",
            "gallery": [], "description": "Temporary integration test property",
            "featured": False, "listing_type": "For Sale", "agent": "", "status": "draft",
        }
        saved = self.successful(self.admin.request("POST", collection, item), "Create property")
        item_id = saved["id"]
        self.cleanups.append(("Delete test property", lambda: self.successful(
            self.admin.request("DELETE", f"{collection}/{item_id}"), "Property cleanup")))
        self.check(bool(item_id), "Collection create assigns a persistent record ID")
        listing = self.successful(self.admin.request("GET", collection), "List properties")
        self.check(any(str(x["id"]) == str(item_id) for x in listing), "Created property is persisted")
        changed = dict(item, price=150000, title=self.marker + " updated")
        result = self.successful(self.admin.request("PUT", f"{collection}/{item_id}", changed), "Update property")
        self.check(float(result["price"]) == 150000, "Collection update saves numeric fields")
        invalid = dict(changed, price=-1)
        self.denied(self.admin.request("PUT", f"{collection}/{item_id}", invalid), "Negative property price")
        invalid = dict(changed, image="javascript:alert(1)")
        self.denied(self.admin.request("PUT", f"{collection}/{item_id}", invalid), "Executable property image URL")
        self.denied(self.admin.request("GET", "/api/collections/users"), "Unknown collection name")

    def pages(self) -> None:
        endpoint = "/api/pages/index"
        original = self.successful(self.admin.request("GET", endpoint), "Read homepage")
        published = original.get("published_content")
        if published is None or original.get("status") != "published":
            self.skip("Homepage draft/publish test requires a published homepage")
            return
        if original["content"] != published:
            self.skip("Homepage has an existing unsaved draft; preserving it")
            return
        baseline = self.successful_public("/index.php?page=index")
        fields = [f for f in original["fields"] if f["type"] in ("text", "textarea") and len(str(f.get("default", ""))) > 12]
        fields.sort(key=lambda f: 0 if "Making Your Homeownership" in str(f.get("default", "")) else 1)
        chosen = next((f for f in fields if html.escape(str(f["default"]), quote=False) in baseline), None)
        if not chosen:
            raise AssertionError("No visible editable homepage text field could be mapped to the public response")
        snapshot = copy.deepcopy(original)

        def restore_page() -> None:
            current = self.successful(self.admin.request("GET", endpoint), "Read homepage for cleanup")
            saved = self.successful(self.admin.request("PUT", endpoint, {
                "title": snapshot["title"], "content": snapshot["content"], "version": current["version"],
            }), "Restore homepage draft")
            self.successful(self.admin.request("POST", endpoint + "/publish", {"version": saved["version"]}), "Restore homepage publication")

        self.cleanups.append(("Restore original homepage", restore_page))
        draft = copy.deepcopy(original["content"])
        dangerous = self.marker + ' <img src=x onerror="alert(1)">'
        draft.setdefault("values", {})[chosen["id"]] = dangerous
        saved = self.successful(self.admin.request("PUT", endpoint, {
            "title": original["title"], "content": draft, "version": original["version"],
        }), "Save homepage draft")
        self.check(saved["content"]["values"][chosen["id"]] == dangerous, "Draft text is stored without losing characters")
        self.check(self.marker not in self.successful_public("/index.php?page=index"), "Draft changes are absent from the public website")
        preview = self.admin.request("GET", "/index.php?page=index&preview=1")
        self.check(preview.status == 200 and self.marker in preview.text, "Authenticated preview shows the draft")
        anonymous_preview = self.public.request("GET", "/index.php?page=index&preview=1")
        self.check(self.marker not in anonymous_preview.text, "Anonymous visitors cannot read draft previews")
        stale = self.admin.request("PUT", endpoint, {
            "title": original["title"], "content": original["content"], "version": original["version"],
        })
        self.check(stale.status == 409, "Stale page version is rejected with HTTP 409")
        result = self.successful(self.admin.request("POST", endpoint + "/publish", {"version": saved["version"]}), "Publish homepage")
        public_html = self.successful_public("/index.php?page=index")
        self.check(self.marker in public_html, "Published content reaches the public website")
        self.check('<img src=x onerror="alert(1)">' not in public_html, "Published text cannot introduce executable HTML")
        self.check("&lt;img" in public_html, "Public renderer escapes HTML in text values")
        current = self.successful(self.admin.request("GET", endpoint), "Reload published homepage")
        self.check(current["published_content"]["values"][chosen["id"]] == dangerous, "Published content persists across API requests")
        # Revert must restore the published state without publishing a new draft.
        second = copy.deepcopy(current["content"])
        second["values"][chosen["id"]] = self.marker + " unpublished second draft"
        saved = self.successful(self.admin.request("PUT", endpoint, {
            "title": original["title"], "content": second, "version": current["version"],
        }), "Save second draft")
        reverted = self.successful(self.admin.request("POST", endpoint + "/revert", {"version": saved["version"]}), "Revert draft")
        self.check(reverted["content"] == reverted["published_content"], "Revert restores the published content")

    def successful_public(self, path: str) -> str:
        response = self.public.request("GET", path)
        if response.status != 200:
            raise AssertionError(f"Public page {path} returned HTTP {response.status}")
        return response.text

    def media(self) -> None:
        self.denied(self.admin.upload("bad.jpg", "image/jpeg", b"<?php echo 'unsafe'; ?>"), "PHP disguised as a JPEG upload")
        self.denied(self.admin.upload("bad.svg", "image/svg+xml", b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'), "SVG upload")
        png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jG3sAAAAASUVORK5CYII=")
        saved = self.successful(self.admin.upload(self.marker + ".png", "image/png", png), "Valid PNG upload")
        media_id = saved["id"]
        self.cleanups.append(("Delete uploaded test image", lambda: self.successful(
            self.admin.request("DELETE", "/api/media/" + urllib.parse.quote(str(media_id), safe="")), "Media cleanup")))
        self.check(saved.get("source") == "upload", "Upload is recorded in the media library")
        self.check(saved.get("url", "").startswith("/uploads/"), "Uploaded file receives a controlled public path")
        self.check(self.marker not in saved["url"], "Uploaded file uses a randomized server filename")
        fetched = self.public.request("GET", saved["url"])
        self.check(fetched.status == 200 and fetched.body.startswith(b"\x89PNG\r\n\x1a\n"), "Uploaded PNG is publicly readable as an image")

    def users_and_roles(self) -> None:
        state = self.successful(self.admin.request("GET", "/api/session"), "Read current account")
        self.denied(self.admin.request("DELETE", "/api/users/" + str(state["user"]["id"])), "Deleting the current administrator")
        editor_email = self.marker.lower() + ".editor@example.invalid"
        editor_password = "Editor!" + secrets.token_hex(14)
        result = self.successful(self.admin.request("POST", "/api/users", {
            "name": "Integration Editor", "email": editor_email,
            "password": editor_password, "role": "editor",
        }), "Create editor")
        editor_id = result["id"]
        self.cleanups.append(("Delete test editor", lambda: self.successful(
            self.admin.request("DELETE", "/api/users/" + str(editor_id)), "Editor cleanup")))
        self.check(not any("password" in key or "hash" in key for key in result), "User response excludes password hashes")
        editor = Client(self.base_url)
        self.successful(editor.request("GET", "/api/session"), "Editor session")
        self.successful(editor.request("POST", "/api/login", {"email": editor_email, "password": editor_password}), "Editor login")
        self.successful(editor.request("GET", "/api/pages"), "Editor content access")
        self.check(True, "Editor can access content management")
        self.denied(editor.request("GET", "/api/users"), "Editor reading account administration")
        self.denied(editor.request("POST", "/api/users", {
            "name": "Escalation", "email": "escalation@example.invalid",
            "password": editor_password, "role": "admin",
        }), "Editor creating an administrator")
        self.denied(editor.request("PUT", "/api/users/" + str(editor_id), {
            "name": "Integration Editor", "email": editor_email, "role": "admin",
        }), "Editor elevating their own role")
        self.denied(editor.request("GET", "/api/backup"), "Editor exporting administrative backups")
        self.denied(editor.request("POST", "/api/backup/restore", {"backup": {}}), "Editor restoring administrative backups")
        self.check(editor.request("GET", "/api/appearance").status == 403, "Editor appearance metadata returns HTTP 403")
        self.check(editor.request("POST", "/api/appearance/preview", {"page_slug": "index", "mode": "styles"}).status == 403, "Editor global design preview returns HTTP 403")

    def inquiries(self) -> None:
        self.successful(self.public.request("GET", "/api/session"), "Public inquiry session")
        payload = {
            "name": "Integration Visitor", "email": self.marker.lower() + "@example.invalid",
            "phone": "+966500000000", "subject": self.marker,
            "message": "Integration inquiry message", "page": "contact", "website": "",
        }
        self.denied(self.public.request("POST", "/api/public/inquiry", payload, csrf=False), "Public inquiry without CSRF")
        invalid = dict(payload, email="not-an-email")
        self.denied(self.public.request("POST", "/api/public/inquiry", invalid), "Inquiry with invalid email")
        self.successful(self.public.request("POST", "/api/public/inquiry", payload), "Submit public inquiry")
        inquiries = self.successful(self.admin.request("GET", "/api/inquiries"), "Read inquiries")
        saved = next((row for row in inquiries if row.get("subject") == self.marker), None)
        self.check(saved is not None, "Public inquiry is saved to the admin inbox")
        inquiry_id = saved["id"]
        self.cleanups.append(("Delete test inquiry", lambda: self.successful(
            self.admin.request("DELETE", "/api/inquiries/" + str(inquiry_id)), "Inquiry cleanup")))
        self.successful(self.admin.request("PUT", "/api/inquiries/" + str(inquiry_id), {"status": "resolved"}), "Resolve inquiry")
        rows = self.successful(self.admin.request("GET", "/api/inquiries"), "Reload inquiries")
        self.check(next(row for row in rows if str(row["id"]) == str(inquiry_id))["status"] == "resolved", "Inquiry status persists")
        trapped = dict(payload, subject=self.marker + " honeypot", website="https://spam.example.invalid")
        response = self.public.request("POST", "/api/public/inquiry", trapped)
        self.check(response.status < 500, "Honeypot submission is handled without server failure")
        rows = self.successful(self.admin.request("GET", "/api/inquiries"), "Check honeypot")
        self.check(not any(row.get("subject") == trapped["subject"] for row in rows), "Honeypot submission is not saved")

    def backups(self) -> None:
        backup = self.successful(self.admin.request("GET", "/api/backup"), "Content backup")
        self.check(backup.get("format") == "housa-cms" and backup.get("version") == 1, "Backup has the documented versioned format")
        self.check(all(key in backup for key in ("settings", "pages", "collections")), "Backup includes settings, pages, and collections")
        forbidden = {"password", "password_hash", "csrf_token", "session_id", "setup_key", "users", "sessions", "login_attempts"}

        def secret_keys(value: Any) -> list[str]:
            found: list[str] = []
            if isinstance(value, dict):
                for key, child in value.items():
                    if key.lower() in forbidden:
                        found.append(key)
                    found.extend(secret_keys(child))
            elif isinstance(value, list):
                for child in value:
                    found.extend(secret_keys(child))
            return found

        self.check(not secret_keys(backup), "Backup excludes authentication and session records")
        encoded = json.dumps(backup)
        self.check(self.password not in encoded and (not self.setup_key or self.setup_key not in encoded), "Backup contains no setup secret or administrator password")
        self.denied(self.admin.request("POST", "/api/backup/restore", {"backup": {"format": "not-housa", "version": 1}}), "Invalid backup schema")
        tampered = copy.deepcopy(backup)
        tampered["settings"]["logo"] = "javascript:alert(1)"
        self.denied(self.admin.request("POST", "/api/backup/restore", {"backup": tampered}), "Backup with unsafe image URL")
        after = self.successful(self.admin.request("GET", "/api/backup"), "Backup after rejected restore")
        self.check(all(after[key] == backup[key] for key in ("settings", "pages", "collections")), "Rejected restore leaves stored content unchanged")

    def private_paths(self) -> None:
        for path in (
            "/storage/cms.sqlite", "/storage/setup.key", "/data/seed.json",
            "/app/bootstrap.php", "/bin/setup.php", "/template/index.html",
            "/CONTRACT.md", "/%2e%2e/storage/cms.sqlite",
        ):
            response = self.public.request("GET", path)
            self.check(response.status in (401, 403, 404), "Private path unavailable: " + path)
            self.check(not response.body.startswith(b"SQLite format 3") and b"<?php" not in response.body, "No private file bytes exposed: " + path)

    def cleanup(self) -> None:
        print("\nCleanup", flush=True)
        for label, callback in reversed(self.cleanups):
            try:
                callback()
                print("  DONE", label, flush=True)
            except Exception as error:
                self.failed += 1
                print(f"  FAIL {label}: {error}", flush=True)
        if self.created_initial_admin:
            print("  NOTE A test administrator remains in this fresh database. Reset test data before real setup or packaging.", flush=True)

    def run(self) -> int:
        started = time.monotonic()
        print("Housa CMS HTTP integration checks", flush=True)
        print("Target:", self.base_url, flush=True)
        self.group("Authentication and setup", self.authenticate)
        if self.failed:
            self.cleanup()
            return self.finish(started)
        try:
            for label, callback in (
                ("Bootstrap, CSRF, and settings validation", self.bootstrap_and_csrf),
                ("Appearance API, publication, previews, and restore", self.appearance),
                ("Collection CRUD and validation", self.collections),
                ("Drafts, publication, concurrency, and output escaping", self.pages),
                ("Media upload validation", self.media),
                ("User permissions", self.users_and_roles),
                ("Public inquiries", self.inquiries),
                ("Backup privacy and atomic validation", self.backups),
                ("Private route protection", self.private_paths),
            ):
                self.group(label, callback)
        finally:
            self.cleanup()
        return self.finish(started)

    def finish(self, started: float) -> int:
        elapsed = time.monotonic() - started
        print(f"\nResult: {self.passed} passed, {self.failed} failed groups/cleanups, {self.skipped} skipped ({elapsed:.1f}s)", flush=True)
        return 1 if self.failed else 0


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--base-url", default=os.environ.get("CMS_TEST_URL", "http://127.0.0.1:4173"))
    parser.add_argument("--email", default=os.environ.get("CMS_TEST_EMAIL", ""))
    parser.add_argument("--password", default=os.environ.get("CMS_TEST_PASSWORD", ""), help="Prefer CMS_TEST_PASSWORD to keep the password out of shell history.")
    parser.add_argument("--setup-key", default=os.environ.get("CMS_TEST_SETUP_KEY", ""), help="Defaults to storage/setup.key for a fresh database.")
    args = parser.parse_args()
    return Suite(args.base_url, args.email, args.password, args.setup_key).run()


if __name__ == "__main__":
    sys.exit(main())
