#!/usr/bin/env python3
"""Mobile Chromium end-to-end test and release screenshot generator."""

from __future__ import annotations

import json
import math
import os
import re
import shutil
import subprocess
import requests
import sys
import time
import unicodedata
from pathlib import Path
from typing import Any

from playwright.sync_api import Page, sync_playwright

ROOT = Path(__file__).resolve().parents[1]
OUTPUT_DIR = Path(os.environ.get("KAMPUSKAFE_SCREENSHOT_DIR", "/mnt/data"))
CHROMIUM = os.environ.get("CHROMIUM_PATH", "/usr/bin/chromium")


def wait_fixture(process: subprocess.Popen[str], timeout: float = 20.0) -> dict[str, Any]:
    deadline = time.monotonic() + timeout
    captured: list[str] = []
    while time.monotonic() < deadline:
        line = process.stdout.readline() if process.stdout else ""
        if line:
            captured.append(line.rstrip())
            if line.startswith("FIXTURE_READY "):
                return json.loads(line.removeprefix("FIXTURE_READY "))
        elif process.poll() is not None:
            break
        else:
            time.sleep(0.05)
    stderr = process.stderr.read() if process.stderr else ""
    raise RuntimeError(f"Fixture server başlamadı. stdout={captured!r} stderr={stderr!r}")


def active_screen(page: Page, name: str) -> None:
    page.locator(f'[data-screen="{name}"].active').wait_for(state="visible")


def searchable_text(value: str) -> str:
    # CSS text-transform ile oluşan I/İ/ı farklarını Türkçe açısından eşitle.
    normalized = str(value).replace("İ", "I").replace("ı", "i")
    folded = unicodedata.normalize("NFKD", normalized.casefold())
    return "".join(char for char in folded if not unicodedata.combining(char))


def screenshot(page: Page, name: str, public_name: str | None = None) -> Path:
    page.wait_for_timeout(350)
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    target = OUTPUT_DIR / name
    page.screenshot(path=str(target), full_page=False)
    if public_name:
        public_target = ROOT / "public" / "screenshots" / public_name
        public_target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(target, public_target)
    return target


def solve_current_board(page: Page, solution: list[int]) -> None:
    cells = page.locator("#board .cell")
    for index in solution:
        cells.nth(index).click()
        cells.nth(index).click()


def rect(page: Page, selector: str) -> dict[str, float]:
    value = page.locator(selector).bounding_box()
    assert value is not None, f"Ölçülemeyen öğe: {selector}"
    return {key: float(value[key]) for key in ("x", "y", "width", "height")}


def assert_rect_stable(
    before: dict[str, float],
    after: dict[str, float],
    tolerance: float = 0.75,
    *,
    include_position: bool = True,
) -> None:
    keys = ("x", "y", "width", "height") if include_position else ("width", "height")
    for key in keys:
        assert abs(before[key] - after[key]) <= tolerance, (
            f"Tahta ölçüsü değişti: {key} önce={before[key]:.3f}, sonra={after[key]:.3f}"
        )


def assert_equal_grid(page: Page, board_selector: str, cell_selector: str, n: int, tolerance: float = 0.75) -> dict[str, float]:
    board = rect(page, board_selector)
    cells: list[dict[str, float]] = page.eval_on_selector_all(
        cell_selector,
        """elements => elements.map(element => {
          const box = element.getBoundingClientRect();
          return {x: box.x, y: box.y, width: box.width, height: box.height};
        })""",
    )
    assert len(cells) == n * n, f"Beklenen {n*n} hücre yerine {len(cells)} hücre var."
    widths = [cell["width"] for cell in cells]
    heights = [cell["height"] for cell in cells]
    assert max(widths) - min(widths) <= tolerance, f"Hücre genişlikleri eşit değil: {min(widths)}–{max(widths)}"
    assert max(heights) - min(heights) <= tolerance, f"Hücre yükseklikleri eşit değil: {min(heights)}–{max(heights)}"
    assert abs(widths[0] - heights[0]) <= tolerance, f"Hücre kare değil: {widths[0]}×{heights[0]}"
    for row in range(n):
        row_cells = cells[row * n:(row + 1) * n]
        assert max(cell["y"] for cell in row_cells) - min(cell["y"] for cell in row_cells) <= tolerance
        assert max(cell["height"] for cell in row_cells) - min(cell["height"] for cell in row_cells) <= tolerance
    for column in range(n):
        column_cells = cells[column::n]
        assert max(cell["x"] for cell in column_cells) - min(cell["x"] for cell in column_cells) <= tolerance
        assert max(cell["width"] for cell in column_cells) - min(cell["width"] for cell in column_cells) <= tolerance
    assert abs(board["width"] - board["height"]) <= 1.0, f"Tahta kare değil: {board}"
    return board


def assert_no_horizontal_overflow(page: Page, selector: str) -> None:
    overflow = page.locator(selector).evaluate(
        "element => Math.max(0, element.scrollWidth - element.clientWidth)"
    )
    assert float(overflow) <= 1.0, f"Yatay taşma var: {selector} +{overflow}px"


def login(page: Page, token: str) -> None:
    page.locator("#communityAccount").click()
    page.locator("#accessDialog[open]").wait_for(state="visible")
    page.locator("#accessToken").fill(token)
    page.locator("#accessForm button[type=submit]").click()
    page.locator("#accessDialog").wait_for(state="hidden")
    page.locator("#communityPanel").wait_for(state="visible")


def main() -> int:
    fixture = subprocess.Popen(
        ["node", str(ROOT / "tests" / "fixture-server.js")],
        cwd=ROOT,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        bufsize=1,
    )
    info: dict[str, Any] = {}
    try:
        info = wait_fixture(fixture)
        base_url = info["baseUrl"]
        solution = [int(value) for value in info["solution"]]
        page_errors: list[str] = []
        console_errors: list[str] = []

        with sync_playwright() as playwright:
            browser = playwright.chromium.launch(
                executable_path=CHROMIUM,
                headless=True,
                args=["--no-sandbox", "--disable-dev-shm-usage"],
            )
            context = browser.new_context(
                viewport={"width": 390, "height": 844},
                device_scale_factor=1,
                locale="tr-TR",
                timezone_id="Europe/Istanbul",
                color_scheme="dark",
                is_mobile=True,
                has_touch=True,
                service_workers="block",
            )
            page = context.new_page()
            page.on("pageerror", lambda error: page_errors.append(str(error)))
            page.on(
                "console",
                lambda message: console_errors.append(message.text)
                if message.type == "error"
                else None,
            )

            def proxy_fetch(_source: Any, payload: dict[str, Any]) -> dict[str, Any]:
                endpoint = str(payload.get("path", ""))
                if not endpoint.startswith("/api/"):
                    return {"status": 404, "headers": {"content-type": "application/json"}, "body": '{"ok":false,"error":"API yolu bulunamadı."}'}
                headers = {str(key): str(value) for key, value in dict(payload.get("headers") or {}).items()}
                headers.pop("origin", None)
                response = requests.request(
                    str(payload.get("method") or "GET"),
                    f"{base_url}{endpoint}",
                    headers=headers,
                    data=payload.get("body") or None,
                    timeout=20,
                )
                return {
                    "status": response.status_code,
                    "headers": {
                        key: value for key, value in response.headers.items()
                        if key.lower() not in {"content-length", "content-encoding", "transfer-encoding", "connection"}
                    },
                    "body": response.text,
                }

            page.expose_binding("__kampusKafeApiFetch", proxy_fetch)
            page.evaluate(
                """
                (() => {
                  try {
                    localStorage.setItem('__kk_probe__', '1');
                    localStorage.removeItem('__kk_probe__');
                  } catch {
                    const values = new Map();
                    Object.defineProperty(window, 'localStorage', {
                      configurable: true,
                      value: {
                        getItem: key => values.has(String(key)) ? values.get(String(key)) : null,
                        setItem: (key, value) => values.set(String(key), String(value)),
                        removeItem: key => values.delete(String(key)),
                        clear: () => values.clear(),
                        key: index => [...values.keys()][Number(index)] ?? null,
                        get length() { return values.size; },
                      },
                    });
                  }
                  const nativeFetch = window.fetch.bind(window);
                  window.fetch = async (input, init = {}) => {
                    const raw = typeof input === 'string' ? input : input.url;
                    let apiPath = null;
                    if (raw.startsWith('./api/')) apiPath = raw.slice(1);
                    else if (raw.startsWith('/api/')) apiPath = raw;
                    else {
                      try {
                        const resolved = new URL(raw, location.href);
                        const position = resolved.pathname.lastIndexOf('/api/');
                        if (position >= 0) apiPath = resolved.pathname.slice(position) + resolved.search;
                      } catch {}
                    }
                    if (apiPath) {
                      const headers = Object.fromEntries(new Headers(init.headers || (typeof input !== 'string' ? input.headers : undefined)));
                      const result = await window.__kampusKafeApiFetch({
                        path: apiPath,
                        method: init.method || (typeof input !== 'string' ? input.method : 'GET') || 'GET',
                        headers,
                        body: init.body == null ? null : String(init.body),
                      });
                      return new Response(result.body, { status: result.status, headers: result.headers });
                    }
                    return nativeFetch(input, init);
                  };
                })();
                """
            )

            html = (ROOT / "public" / "index.html").read_text(encoding="utf-8")
            pwa_shell = (ROOT / "public" / "assets" / "pwa-shell.js").read_text(encoding="utf-8")
            html = re.sub(
                r'<script\s+defer\s+src="\./assets/pwa-shell\.js\?v=[^"]+"></script>',
                '',
                html,
                flags=re.IGNORECASE,
            )
            html = html.replace('</body>', f'<script>{pwa_shell}</script></body>')
            import base64
            icon_data = base64.b64encode((ROOT / "public" / "icons" / "icon-180.png").read_bytes()).decode("ascii")
            html = html.replace('src="./icons/icon-180.png"', f'src="data:image/png;base64,{icon_data}"')
            html = re.sub(r'<link\s+[^>]*(?:rel="(?:manifest|icon|apple-touch-icon)")[^>]*>', '', html, flags=re.IGNORECASE)
            page.set_content(html, wait_until="load")
            active_screen(page, "home")
            assert page.locator("#difficultyList [data-difficulty]").count() == 3
            page.wait_for_function("""() => document.querySelector('[data-difficulty="easy"] .difficulty-progress span')?.textContent?.trim() === '6 bölüm'""")
            difficulty_text = page.locator("#difficultyList").inner_text()
            assert "5 +" not in difficulty_text, f"Eski topluluk toplam metni kaldı: {difficulty_text!r}"
            assert "6 bölüm" in difficulty_text, f"Doğrudan toplam bölüm sayısı görünmüyor: {difficulty_text!r}"
            assert not re.search(r"\b\d+\s*[×x]\s*\d+\b", difficulty_text), (
                f"Zorluk kartları tahta boyutuna bağlanmış görünüyor: {difficulty_text!r}"
            )
            assert_no_horizontal_overflow(page, '[data-screen="home"]')
            visible_text = searchable_text(page.locator("body").inner_text())
            for banned in ("15 resmi", "resmi bölüm", "resmi bulmaca", "reklamsız"):
                assert banned not in visible_text, f"Yasaklı metin göründü: {banned}"
            screenshot(page, "kampuskafe-home-final.png", "home-dark.png")

            page.locator('#difficultyList [data-difficulty="easy"]').click()
            active_screen(page, "levels")
            assert page.locator("#levelList [data-level]").count() == 5
            assert page.locator("#levelBadge").count() == 0
            level_text = page.locator(".level-intro").inner_text()
            assert not re.search(r"\b\d+\s*[×x]\s*\d+\b", level_text), (
                f"Bölüm ekranında zorluk-tahta boyutu bağı kaldı: {level_text!r}"
            )
            assert_no_horizontal_overflow(page, '[data-screen="levels"]')

            # 10 testten geçip yayınlanan topluluk bölümü, laboratuvarda
            # kalmaya devam ederken normal zorluk ekranında da otomatik görünür.
            community_level = page.locator(f'#levelList [data-community-level="{info["publishedPuzzleId"]}"]')
            community_level.wait_for(state="visible")
            assert community_level.get_attribute("data-level-number") == "6"
            assert page.locator("#levelList .level-entry").count() == 6
            assert page.locator("#communityLevelsTitle").count() == 0
            assert page.locator("#communityLevelList").count() == 0
            community_level_text = searchable_text(community_level.inner_text())
            for expected in (
                info["publishedPuzzleTitle"],
                info["publishedCreator"],
                info["publishedUniversity"],
                info["publishedCommunity"],
            ):
                assert searchable_text(expected) in community_level_text, (
                    f"Topluluk bölüm kartında kaynak bilgisi eksik: {expected!r} / {community_level_text!r}"
                )
            assert "test" not in community_level_text, community_level_text
            community_meta = community_level.locator(".level-entry-meta i")
            assert community_meta.count() == 1
            community_meta_text = community_meta.inner_text().strip()
            assert community_meta_text.startswith("6×6 · "), community_meta_text
            assert len(community_meta_text) > len("6×6 · "), "Yayın tarihi boş"
            levels_count_text = searchable_text(page.locator("#levelsCount").inner_text().strip())
            assert levels_count_text == "6 bolum", levels_count_text
            section_count_text = searchable_text(page.locator("#levelSectionCount").inner_text().strip())
            assert section_count_text == "6 bolum", section_count_text
            screenshot(page, "kampuskafe-community-levels-final.png")

            community_level.click()
            active_screen(page, "game")
            page.wait_for_timeout(100)
            assert page.locator("#gameTitle").inner_text().strip() == info["publishedPuzzleTitle"]
            game_credit_raw = page.locator("#gameSubtitle").inner_text()
            game_credit = searchable_text(game_credit_raw)
            for expected in (info["publishedCreator"], info["publishedUniversity"], info["publishedCommunity"]):
                assert searchable_text(expected) in game_credit, (expected, game_credit_raw)
            assert page.locator("#board .cell").count() == 36
            page.locator("#board .cell").nth(0).click()
            page.locator("#gameBack").click()
            active_screen(page, "levels")
            page.locator("#levelsBack").click()
            active_screen(page, "home")
            page.locator("#homeContinue").click()
            active_screen(page, "game")
            assert "#6" in page.locator("#gameSubtitle").inner_text(), "Yarım kalan topluluk bölümünün 6. bölüm numarası kayboldu"
            page.locator("#gameBack").click()
            active_screen(page, "levels")

            page.locator('#levelList [data-level="1"]').click()
            active_screen(page, "game")
            page.wait_for_timeout(250)
            board_count = page.locator("#board .cell").count()
            assert board_count == 36, f"oyun hücre sayısı={board_count}; page_errors={page_errors}; console_errors={console_errors}"
            viewport_content = page.locator('meta[name="viewport"]').get_attribute("content") or ""
            assert "maximum-scale=1" in viewport_content and "user-scalable=no" in viewport_content
            app_metrics = page.evaluate("""() => { const app=document.querySelector('.app').getBoundingClientRect(); return {appHeight:app.height, visualHeight:visualViewport?.height || innerHeight, appBottom:app.bottom}; }""")
            assert abs(float(app_metrics["appHeight"]) - float(app_metrics["visualHeight"])) <= 1.5, app_metrics
            assert abs(float(app_metrics["appBottom"]) - float(app_metrics["visualHeight"])) <= 1.5, app_metrics
            assert page.locator("#board").evaluate("element => getComputedStyle(element).touchAction") == "none"
            assert page.locator("#board").evaluate("element => { const event=new MouseEvent('dblclick',{bubbles:true,cancelable:true}); return !element.dispatchEvent(event) && event.defaultPrevented; }")
            game_board_before = assert_equal_grid(page, "#board", "#board .cell", 6)
            cells = page.locator("#board .cell")
            for index, taps in ((0, 2), (1, 2), (6, 1), (7, 2), (20, 1)):
                for _ in range(taps):
                    cells.nth(index).click()
            page.wait_for_timeout(80)
            assert page.locator("#board .cell.star").count() == 3
            assert page.locator("#board .cross-shape").count() >= 2
            game_board_after = assert_equal_grid(page, "#board", "#board .cell", 6)
            assert_rect_stable(game_board_before, game_board_after)
            assert_no_horizontal_overflow(page, '[data-screen="game"]')

            page.locator("#gameMore").click()
            page.locator("#gameMenuDialog[open]").wait_for(state="visible")
            page.locator("#restartAction").click()
            page.locator("#confirmDialog[open]").wait_for(state="visible")
            page.locator("#confirmOk").click()
            page.wait_for_timeout(80)
            assert page.locator("#board .cell.star").count() == 0
            assert page.locator("#board .cell.cross").count() == 0
            assert_rect_stable(game_board_before, assert_equal_grid(page, "#board", "#board .cell", 6))
            screenshot(page, "kampuskafe-game-final.png", "game-dark.png")

            solve_current_board(page, solution)
            page.locator("#completeDialog[open]").wait_for(state="visible")
            assert page.locator("#completeDialog .complete-actions button").count() == 2
            screenshot(page, "kampuskafe-complete-final.png")
            page.locator("#completeClose").click()
            page.locator("#completeDialog").wait_for(state="hidden")
            assert page.locator("#nextFab").is_visible()

            page.locator("#gameBack").click()
            active_screen(page, "levels")
            page.locator("#levelsBack").click()
            active_screen(page, "home")

            page.locator("#openCommunity").click()
            active_screen(page, "community")

            # Aynı yayın laboratuvarın Yayınlanan sekmesinde de görünmeye devam eder.
            lab_play = page.locator(f'[data-community-play="{info["publishedPuzzleId"]}"]')
            lab_play.wait_for(state="visible")
            lab_card = lab_play.locator("xpath=ancestor::article[1]")
            lab_text = searchable_text(lab_card.inner_text())
            for expected in (
                info["publishedPuzzleTitle"],
                info["publishedCreator"],
                info["publishedUniversity"],
                info["publishedCommunity"],
            ):
                assert searchable_text(expected) in lab_text, (
                    f"Laboratuvar yayın kartında kaynak bilgisi eksik: {expected!r} / {lab_text!r}"
                )
            assert "test" not in lab_text, f"Yayınlanan bölüm kartında test sayısı gösteriliyor: {lab_text!r}"
            lab_meta = lab_card.locator(".card-meta .pill")
            assert lab_meta.count() == 1
            lab_meta_text = lab_meta.inner_text().strip()
            assert lab_meta_text.startswith("6×6 · "), lab_meta_text
            assert len(lab_meta_text) > len("6×6 · "), "Laboratuvar yayın tarihi boş"
            screenshot(page, "kampuskafe-community-lab-final.png")

            login(page, info["creatorToken"])
            page.locator('[data-community-tab="builder"]').click()
            page.locator("#openBuilderBtn").wait_for(state="visible")
            page.locator("#openBuilderBtn").click()
            active_screen(page, "builder")
            page.wait_for_timeout(250)

            size_input = page.locator("#builderSize")
            size_input.fill("14")
            size_input.dispatch_event("change")
            page.wait_for_timeout(120)
            assert page.locator("#builderBoard .builder-cell").count() == 196
            assert page.locator("#builderBoard .builder-cell.unassigned").count() == 196
            assert page.locator("#regionPalette .region-swatch").count() == 1
            assert "Boş 196" in page.locator("#regionPalette").inner_text()
            assert float(page.locator("#builderName").evaluate("element => parseFloat(getComputedStyle(element).fontSize)")) >= 16
            builder_board_before = assert_equal_grid(page, "#builderBoard", "#builderBoard .builder-cell", 14)

            # Bölge kimliği renk seçiminden bağımsızdır: yeni bölge oluşturulur,
            # boyanır ve Silgi ile yeniden atanmamış duruma getirilebilir.
            page.locator('[data-region-action="new"]').click()
            assert page.locator("#regionPalette .region-swatch").count() == 2
            builder_cells = page.locator("#builderBoard .builder-cell")
            builder_cells.nth(0).click()
            assert builder_cells.nth(0).get_attribute("data-region") == "1"
            page.locator('#regionPalette [data-region="0"]').click()
            builder_cells.nth(1).click()
            assert builder_cells.nth(1).get_attribute("data-region") == "0"
            page.locator('#regionPalette [data-region="-1"]').click()
            builder_cells.nth(1).click()
            assert builder_cells.nth(1).get_attribute("data-region") == "-1"
            assert builder_cells.nth(1).evaluate("element => element.classList.contains('unassigned')")

            # 14x14 tahta için kalan benzersiz bölge kimliklerini oluştur.
            for _ in range(12):
                page.locator('[data-region-action="new"]').click()
            assert page.locator("#regionPalette .region-swatch").count() == 14
            assert page.locator('[data-region-action="new"]').is_disabled()

            page.locator('[data-builder-mode="solution"]').click()
            for index in (3, 20, 37, 54, 71, 88):
                builder_cells.nth(index).click()
            page.wait_for_timeout(80)
            assert page.locator("#builderBoard .builder-cell.has-star").count() == 6
            builder_board_after_stars = assert_equal_grid(page, "#builderBoard", "#builderBoard .builder-cell", 14)
            assert_rect_stable(builder_board_before, builder_board_after_stars, include_position=False)

            # Şablon penceresini açmak hiçbir veriyi değiştirmez. Varsayılan
            # davranış yalnızca boş hücreleri doldurur ve boyanmış hücreyi korur.
            page.locator("#builderTemplateButton").click()
            page.locator("#builderTemplateDialog[open]").wait_for(state="visible")
            assert page.locator("#builderTemplateOnlyBlank").is_checked()
            assert page.locator("#builderBoard .builder-cell.has-star").count() == 6
            screenshot(page, "kampuskafe-template-dialog-final.png")
            page.locator('[data-builder-template="rows"]').click()
            page.locator("#confirmDialog[open]").wait_for(state="visible")
            page.locator("#confirmOk").click()
            page.wait_for_timeout(120)
            assert page.locator("#builderBoard .builder-cell.has-star").count() == 6
            assert page.locator("#builderBoard .builder-cell.unassigned").count() == 0
            assert builder_cells.nth(0).get_attribute("data-region") == "1"
            assert_rect_stable(builder_board_before, assert_equal_grid(page, "#builderBoard", "#builderBoard .builder-cell", 14), include_position=False)

            # Kullanıcı isterse seçimi kaldırıp bütün boyamayı değiştirebilir;
            # çözüm yıldızları yine korunur.
            page.locator("#builderTemplateButton").click()
            page.locator("#builderTemplateOnlyBlank").uncheck()
            page.locator('[data-builder-template="columns"]').click()
            page.locator("#confirmDialog[open]").wait_for(state="visible")
            page.locator("#confirmOk").click()
            page.wait_for_timeout(120)
            assert page.locator("#builderBoard .builder-cell.has-star").count() == 6
            assert builder_cells.nth(0).get_attribute("data-region") == "0"

            # Sürükleyerek boyama tek hareket olarak çalışır ve hücre geometrisi
            # değişmez. Kopuk bir bölge anında kırmızı uyarıyla işaretlenir.
            page.locator('[data-builder-mode="region"]').click()
            page.locator('#regionPalette [data-region="2"]').click()
            builder_cells.nth(0).scroll_into_view_if_needed()
            first = builder_cells.nth(0).bounding_box()
            fifth = builder_cells.nth(4).bounding_box()
            assert first is not None and fifth is not None
            page.mouse.move(first["x"] + first["width"] / 2, first["y"] + first["height"] / 2)
            page.mouse.down()
            page.mouse.move(fifth["x"] + fifth["width"] / 2, fifth["y"] + fifth["height"] / 2, steps=12)
            page.mouse.up()
            page.wait_for_timeout(100)
            assert all(builder_cells.nth(index).get_attribute("data-region") == "2" for index in range(5))
            builder_cells.nth(195).click()
            page.wait_for_timeout(80)
            assert page.locator('#regionPalette [data-region="2"].warning').count() == 1
            page.locator('#regionPalette [data-region="13"]').click()
            builder_cells.nth(195).click()
            page.wait_for_timeout(80)
            assert page.locator('#regionPalette [data-region="2"].warning').count() == 0
            assert page.locator("#builderBoard .builder-cell.has-star").count() == 6
            assert_rect_stable(builder_board_before, assert_equal_grid(page, "#builderBoard", "#builderBoard .builder-cell", 14), include_position=False)

            fit_width = rect(page, "#builderBoard")["width"]
            page.locator("#builderZoomIn").click()
            page.wait_for_timeout(100)
            zoomed_width = rect(page, "#builderBoard")["width"]
            assert zoomed_width >= fit_width * 1.20, (fit_width, zoomed_width)
            assert_equal_grid(page, "#builderBoard", "#builderBoard .builder-cell", 14)

            # Yakınlaştırılmış tahta, düzenleme yapmadan tek parmakla kaydırılabilen
            # ayrı bir taşıma moduna geçer.
            region_before_pan = builder_cells.nth(0).get_attribute("data-region")
            stars_before_pan = page.locator("#builderBoard .builder-cell.has-star").count()
            page.locator('[data-builder-mode="pan"]').click()
            assert page.locator("#builderBoard").get_attribute("data-mode") == "pan"
            pan_touch_action = page.locator("#builderBoard").evaluate("element => getComputedStyle(element).touchAction")
            assert "pan-x" in pan_touch_action and "pan-y" in pan_touch_action, pan_touch_action
            builder_cells.nth(0).click()
            assert builder_cells.nth(0).get_attribute("data-region") == region_before_pan
            assert page.locator("#builderBoard .builder-cell.has-star").count() == stars_before_pan
            page.locator('[data-builder-mode="region"]').click()

            page.locator("#builderZoomFit").click()
            page.wait_for_timeout(100)
            assert math.isclose(rect(page, "#builderBoard")["width"], fit_width, abs_tol=1.0)

            page.locator("#builderDifficulty").select_option("hard")
            assert size_input.input_value() == "14"
            assert page.locator("#builderBoard .builder-cell").count() == 196
            assert page.locator("#builderBoard .builder-cell.has-star").count() == 6
            assert_no_horizontal_overflow(page, '[data-screen="builder"]')
            screenshot(page, "kampuskafe-builder-final.png")

            page.locator("#builderBack").click()
            active_screen(page, "community")
            page.locator("#communityAccount").click()
            page.locator("#accessLogout").wait_for(state="visible")
            page.locator("#accessLogout").click()
            page.locator("#accessDialog").wait_for(state="hidden")
            login(page, info["reviewerToken"])

            page.locator('[data-community-tab="review"]').click()
            play_button = page.locator(f'[data-community-play="{info["puzzleId"]}"]')
            play_button.wait_for(state="visible")
            play_button.click()
            active_screen(page, "game")
            assert page.locator("#reviewStrip").is_visible()
            assert page.locator("#board .cell").count() == 36
            solve_current_board(page, solution)
            page.locator("#completeDialog[open]").wait_for(state="visible")
            next_text = page.locator("#completeNext").inner_text().lower()
            next_text_ascii = "".join(ch for ch in unicodedata.normalize("NFKD", next_text) if not unicodedata.combining(ch))
            assert "geri bildirim ver" in next_text_ascii, f"Beklenmeyen tamamlama eylemi: {next_text!r}"
            page.locator("#completeNext").click()
            page.locator("#feedbackDialog[open]").wait_for(state="visible")

            page.locator('input[name="playability"][value="unplayable"]').check()
            assert all(
                page.locator('input[name="difficultyVote"]').nth(i).is_disabled()
                for i in range(page.locator('input[name="difficultyVote"]').count())
            )
            page.locator('input[name="playability"][value="playable"]').check()
            assert all(
                not page.locator('input[name="difficultyVote"]').nth(i).is_disabled()
                for i in range(page.locator('input[name="difficultyVote"]').count())
            )
            page.locator('input[name="difficultyVote"][value="hard"]').check()
            page.locator("#feedbackForm button[type=submit]").click()
            active_screen(page, "community")
            page.locator('[data-community-tab="review"].active').wait_for(state="visible")
            page.locator("#communityPanel").wait_for(state="visible")
            assert page.locator(f'[data-community-play="{info["puzzleId"]}"]').count() == 0

            page.locator("#communityBack").click()
            active_screen(page, "home")
            page.evaluate("document.documentElement.dataset.theme='light'")
            screenshot(page, "kampuskafe-home-light-final.png", "home-light.png")

            context.close()
            browser.close()

        if page_errors:
            raise AssertionError(f"Sayfa JavaScript hataları: {page_errors}")
        allowed_console_fragments = ("service worker",)
        unexpected_console = [
            text for text in console_errors
            if not any(fragment in text.lower() for fragment in allowed_console_fragments)
        ]
        if unexpected_console:
            raise AssertionError(f"Beklenmeyen console.error kayıtları: {unexpected_console}")

        print(json.dumps({
            "ok": True,
            "viewport": "390x844",
            "browser": "Chromium",
            "checks": [
                "ana ekran ve zorluk seçimi",
                "Başlangıç ve yayınlanan topluluk bölümlerinin 1, 2, 3… biçiminde tek seçim ekranı",
                "oyun tahtası ve minimal tamamlama penceresi",
                "14x14 boş tuval, yeni bölge ve silgi akışı",
                "çift dokunma zoom engeli ve tam viewport PWA kabuğu",
                "yayınlanan topluluk bölümünün ana katalog ve laboratuvarda birlikte görünmesi",
                "üretici, üniversite ve topluluk kaynak bilgisinin görünmesi",
                "üretici/testçi erişim rolleri",
                "çözüm sonrası oynanabilirlik ve zorluk geri bildirimi",
                "koyu ve açık tema ekran görüntüleri",
            ],
        }, ensure_ascii=False, indent=2))
        return 0
    finally:
        if fixture.poll() is None:
            fixture.terminate()
            try:
                fixture.wait(timeout=5)
            except subprocess.TimeoutExpired:
                fixture.kill()
                fixture.wait(timeout=5)


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except Exception as error:  # noqa: BLE001 - test runner should show full context
        print(f"BROWSER_E2E_FAILED: {error}", file=sys.stderr)
        raise
