from __future__ import annotations

import os
import re
import threading
from pathlib import Path

import pytest

if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
    pytest.skip("packaged mobile Home bootstrap runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright

from fake_gitea import AVAILABLE_ISSUES, FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server


@pytest.mark.parametrize(("width", "height"), [(390, 844)])
def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
    tmp_path: Path, width: int, height: int
):
    archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
    assert len(archives) == 1, "browser job must download exactly one assembled release archive"

    fake = FakeGiteaServer(("127.0.0.1", 0))
    fake.assigned_issue_numbers = []
    fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
    fake_thread.start()
    fake_url = f"http://127.0.0.1:{fake.server_port}"

    try:
        with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
            browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
            context = browser.new_context(
                viewport={"width": width, "height": height}, ignore_https_errors=True
            )
            page = context.new_page()
            page.goto(origin + "/", wait_until="networkidle")
            page.locator('input[name="device_label"]').fill("First task release phone")
            page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
            page.locator("#submit-sign-in").click()
            page.wait_for_url(origin + "/", wait_until="networkidle")
            expect(page.locator("#my-work-status")).to_contain_text("No assigned work")

            page.locator('[data-mobile-task="work"]').click()
            sheet = page.locator("#mobile-first-task")
            expect(sheet).to_be_visible()
            expect(page.locator("#mobile-first-task-find")).to_be_focused()
            for selector in ("#mobile-first-task-find", "#mobile-first-task-create", "#mobile-first-task-setup"):
                bounds = page.locator(selector).bounding_box()
                assert bounds and bounds["height"] >= 44
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")

            page.locator("#mobile-first-task-find").click()
            expect(sheet).to_be_hidden()
            expect(page.locator("#find-work-sheet")).to_be_visible()
            page.locator("#close-find-work").click()

            context.set_offline(True)
            page.locator('[data-mobile-task="work"]').click()
            expect(sheet).to_be_visible()
            expect(page.locator("#mobile-first-task-find")).to_be_disabled()
            expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now")
            expect(page.locator("#mobile-first-task-create")).to_be_enabled()
            expect(page.locator("#mobile-first-task-create")).to_be_focused()

            page.locator("#close-mobile-first-task").click()
            context.set_offline(False)
            page.evaluate(
                """() => {
                  document.querySelector('[data-mobile-today-hud]').hidden = false;
                  localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
                  const productionCoach = document.querySelector('[data-mobile-first-task-coach]');
                  const isolatedCoach = productionCoach.cloneNode(true);
                  productionCoach.replaceWith(isolatedCoach);
                  window.firstTaskOutcomeProbe = createMobileFirstTask({
                    getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
                    coach: isolatedCoach,
                  });
                  return window.firstTaskOutcomeProbe.refresh();
                }"""
            )
            coach = page.locator("[data-mobile-first-task-coach]")
            expect(coach).to_be_visible()
            expect(coach).to_contain_text("Complete your first task")
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
            assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
            expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
            expect(coach).to_be_hidden()
            expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")

            fresh_context = browser.new_context(
                viewport={"width": width, "height": height}, ignore_https_errors=True
            )
            fresh_page = fresh_context.new_page()
            fresh_page.goto(origin + "/", wait_until="networkidle")
            fresh_page.locator('input[name="device_label"]').fill("Replacement release phone")
            fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
            fresh_page.locator("#submit-sign-in").click()
            fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
            expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
            assert fresh_page.evaluate(
                "localStorage.getItem('stackchain.first-task.v1:timmy')"
            ) == "complete"
            fresh_page.locator('[data-mobile-task="work"]').click()
            expect(fresh_page.locator("#mobile-first-task")).to_be_hidden()
            fresh_context.close()
            browser.close()
    finally:
        fake.shutdown()
        fake.server_close()
        fake_thread.join(timeout=5)


@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
    tmp_path: Path, width: int, height: int
):
    archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
    assert len(archives) == 1, "browser job must download exactly one assembled release archive"

    fake = FakeGiteaServer(("127.0.0.1", 0))
    fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
    fake_thread.start()
    fake_url = f"http://127.0.0.1:{fake.server_port}"
    browser_errors: list[str] = []
    failed_responses: list[str] = []
    workspace_requests: list[str] = []
    launch_transfer_events: list[str] = []
    live_requests: list[str] = []

    try:
        with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
            browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
            context = browser.new_context(
                viewport={"width": width, "height": height}, ignore_https_errors=True
            )
            page = context.new_page()
            cdp = context.new_cdp_session(page)
            cdp.send("Network.enable")
            cdp.send(
                "Network.emulateNetworkConditions",
                {
                    "offline": False,
                    "latency": 100,
                    "downloadThroughput": 48 * 1024,
                    "uploadThroughput": 48 * 1024,
                    "connectionType": "cellular3g",
                },
            )
            page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
            page.on(
                "console",
                lambda message: browser_errors.append(message.text)
                if message.type == "error"
                else None,
            )
            page.on(
                "response",
                lambda response: failed_responses.append(f"{response.status} {response.url}")
                if response.status >= 400
                else None,
            )
            page.on(
                "request",
                lambda request: (
                    workspace_requests.append(request.url),
                    launch_transfer_events.append("workspace-requested"),
                )
                if "feature-today-timer-" in request.url else None,
            )
            page.on(
                "requestfinished",
                lambda request: launch_transfer_events.append("core-finished")
                if "/runtime-" in request.url else None,
            )
            page.on(
                "request",
                lambda request: live_requests.append(request.url)
                if "/api/v1/live" in request.url
                else None,
            )

            page.goto(origin + "/", wait_until="networkidle")
            page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
            page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
            page.locator("#submit-sign-in").click()
            page.wait_for_url(origin + "/", wait_until="networkidle")
            assert launch_transfer_events.index("workspace-requested") < (
                launch_transfer_events.index("core-finished")
            ), launch_transfer_events

            expect(page.locator("#my-work-status")).to_contain_text("2")
            initial_live_requests = len(live_requests)
            page.evaluate(
                """
                () => {
                  window.scrollTo(0, 0);
                  const surface = document.querySelector('#my-work');
                  surface.setPointerCapture = () => {};
                  surface.releasePointerCapture = () => {};
                  const pointer = (type, y) => surface.dispatchEvent(new PointerEvent(type, {
                    bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:y,
                  }));
                  pointer('pointerdown', 10);
                  pointer('pointermove', 90);
                }
                """
            )
            expect(page.locator("#mobile-pull-refresh")).to_have_text("Release to refresh")
            page.evaluate(
                "document.querySelector('#my-work').dispatchEvent(new PointerEvent('pointerup', "
                "{bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:90}))"
            )
            expect(page.locator("#mobile-pull-refresh")).to_have_text("My Work is up to date")
            assert len(live_requests) == initial_live_requests + 1
            dock = page.locator("#mobile-task-dock")
            expect(dock).to_be_visible()
            expect(dock.locator("button")).to_have_count(5)
            for control in dock.locator("button").all():
                bounds = control.bounding_box()
                assert bounds and bounds["height"] >= 44

            page.locator("#work-settings-toggle").click()
            tomorrow = page.locator("#plan-tomorrow")
            expect(tomorrow).to_be_visible()
            tomorrow_bounds = tomorrow.bounding_box()
            assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
            tomorrow.click()
            expect(page.locator("#plan-today-sheet")).to_be_visible()
            expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
            expect(page.locator("#save-and-start-today")).to_be_hidden()
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
            page.locator("#cancel-plan-today").click()

            page.locator('[data-mobile-task="queues"]').click()
            delivery_queue = page.locator('[data-mobile-queue="delivery"]')
            expect(delivery_queue).to_be_visible()
            expect(delivery_queue).to_contain_text("Delivery")
            delivery_bounds = delivery_queue.bounding_box()
            assert delivery_bounds and delivery_bounds["height"] >= 44
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
            page.locator("#close-mobile-queues").click()

            page.locator("#app-menu-toggle").click()
            page.locator("#open-insights").click()
            expect(page.locator("#insights-sheet")).to_be_visible()
            expect(page.locator("#insights-heading")).to_have_text("Insights")
            page.locator("#close-insights").click()
            expect(page.locator("#insights-sheet")).to_be_hidden()
            expect(page.locator("#my-work")).to_be_visible()
            expect(dock).to_be_visible()

            page.locator("#app-menu-toggle").click()
            page.locator("#open-device-setup").click()
            expect(page.locator("#device-setup-sheet")).to_be_visible()
            expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
            expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
            expect(page.locator("body > header")).to_have_attribute("inert", "")
            expect(page.locator("main")).to_have_attribute("inert", "")
            expect(dock).to_have_attribute("inert", "")
            expect(page.locator("#close-device-setup")).to_be_focused()
            page.keyboard.press("Shift+Tab")
            expect(page.locator("#clear-private-device-data")).to_be_focused()
            page.keyboard.press("Tab")
            expect(page.locator("#close-device-setup")).to_be_focused()
            for selector in ("#clear-device-caches", "#clear-private-device-data"):
                bounds = page.locator(selector).bounding_box()
                assert bounds and bounds["height"] >= 44
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
            page.go_back()
            expect(page.locator("#device-setup-sheet")).to_be_hidden()
            expect(page.locator("#open-device-setup")).to_be_focused()
            expect(page.locator("body > header")).not_to_have_attribute("inert", "")
            expect(page.locator("main")).not_to_have_attribute("inert", "")
            expect(dock).not_to_have_attribute("inert", "")

            page.locator("#active-devices").click()
            expect(page.locator("#active-devices-sheet")).to_be_visible()
            expect(page.locator("#security-activity-section")).to_be_visible()
            expect(page.locator("body > header")).to_have_attribute("inert", "")
            expect(page.locator("main")).to_have_attribute("inert", "")
            expect(dock).to_have_attribute("inert", "")
            expect(page.locator("#close-active-devices")).to_be_focused()
            expect(page.locator("#active-devices-status")).to_contain_text("active device")
            expect(page.locator("#enrolled-passkeys-status")).not_to_contain_text("Loading")
            page.keyboard.press("Shift+Tab")
            assert page.evaluate(
                """
                () => {
                  const controls = [...document.querySelector('#active-devices-sheet').querySelectorAll(
                    'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
                  )].filter(control => !control.hidden);
                  return document.activeElement === controls.at(-1);
                }
                """
            )
            page.keyboard.press("Tab")
            expect(page.locator("#close-active-devices")).to_be_focused()
            security_nav = page.locator(".security-section-nav")
            expect(security_nav).to_be_visible()
            for control in security_nav.locator("button").all():
                bounds = control.bounding_box()
                assert bounds and bounds["height"] >= 44
            page.locator("#security-section-devices").click()
            expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
            page.go_back()
            expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
            expect(page.locator("body > header")).to_have_attribute("inert", "")
            expect(page.locator("main")).to_have_attribute("inert", "")
            expect(dock).to_have_attribute("inert", "")
            page.go_back()
            expect(page.locator("#active-devices-sheet")).to_be_hidden()
            expect(page.locator("#active-devices")).to_be_focused()
            expect(page.locator("body > header")).not_to_have_attribute("inert", "")
            expect(page.locator("main")).not_to_have_attribute("inert", "")
            expect(dock).not_to_have_attribute("inert", "")

            assert len(workspace_requests) == 1, workspace_requests
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
            assert browser_errors == []
            assert failed_responses == []
            browser.close()
    finally:
        fake.shutdown()
        fake.server_close()
        fake_thread.join(timeout=5)


def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_path: Path):
    archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
    assert len(archives) == 1, "browser job must download exactly one assembled release archive"

    fake = FakeGiteaServer(("127.0.0.1", 0))
    fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
    fake_thread.start()
    fake_url = f"http://127.0.0.1:{fake.server_port}"
    attempts: list[str] = []

    try:
        with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
            browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
            page = browser.new_page(viewport={"width": 390, "height": 844})
            page.goto(origin + "/", wait_until="networkidle")

            def interrupt_once(route):
                attempts.append(route.request.url)
                if len(attempts) == 1:
                    route.abort("failed")
                else:
                    route.continue_()

            page.route(re.compile(r"/feature-today-timer-[^/?]+\.js(?:\?.*)?$"), interrupt_once)
            page.locator('input[name="device_label"]').fill("Workspace recovery release phone")
            page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
            page.locator("#submit-sign-in").click()
            page.wait_for_url(origin + "/", wait_until="networkidle")

            page.wait_for_timeout(1500)
            resources = page.evaluate(
                "performance.getEntriesByType('resource').map(entry => entry.name)"
            )
            state = {
                "attempts": attempts,
                "resources": resources,
                "action": page.locator("#my-work-action-status").text_content(),
                "retry_hidden": page.locator("#retry-workspace").is_hidden(),
                "work": page.locator("#my-work-status").text_content(),
            }
            assert len(attempts) == 2, state
            assert "2" in (state["work"] or ""), state
            expect(page.locator("#retry-workspace")).to_be_hidden()
            expect(page.locator("#my-work-action-status")).not_to_contain_text("Workspace unavailable")
            assert any("feature-today-timer-" in url and "retry=" in url for url in resources), state
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
            browser.close()
    finally:
        fake.shutdown()
        fake.server_close()
        fake_thread.join(timeout=5)


def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: Path):
    archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
    assert len(archives) == 1, "browser job must download exactly one assembled release archive"

    fake = FakeGiteaServer(("127.0.0.1", 0))
    fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
    fake_thread.start()
    fake_url = f"http://127.0.0.1:{fake.server_port}"
    browser_errors: list[str] = []

    try:
        with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
            browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
            page = browser.new_page(viewport={"width": 390, "height": 844})
            page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
            page.goto(origin + "/", wait_until="networkidle")
            page.locator('input[name="device_label"]').fill("Delivery recovery release phone")
            page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
            page.locator("#submit-sign-in").click()
            page.wait_for_url(origin + "/", wait_until="networkidle")

            page.evaluate(
                """
                () => {
                  window.__deliveryAttempts = 0;
                  window.__deliverySettled = new Promise(resolve => { window.__finishDelivery = resolve; });
                  window.__releaseRecovery = createMobileDeliveryRecovery({
                    getItems: () => [{outbox_id:'release-retry', delivery_state:'waiting', title:'Post release note'}],
                    activate: async () => {
                      window.__deliveryAttempts += 1;
                      await window.__deliverySettled;
                    },
                  });
                  window.__releaseRecovery.open();
                  window.__firstRecovery = window.__releaseRecovery.activate();
                  window.__secondRecovery = window.__releaseRecovery.activate();
                }
                """
            )
            action = page.locator("#mobile-delivery-recovery-action")
            expect(page.locator("#mobile-delivery-recovery")).to_be_visible()
            expect(action).to_be_disabled()
            expect(page.locator("#mobile-delivery-recovery-status")).to_have_text("Working…")
            assert page.evaluate("window.__deliveryAttempts") == 1
            bounds = action.bounding_box()
            assert bounds and bounds["height"] >= 44
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")

            page.evaluate("window.__finishDelivery()")
            page.evaluate("Promise.all([window.__firstRecovery, window.__secondRecovery])")
            expect(action).to_be_enabled()
            assert page.evaluate("window.__deliveryAttempts") == 1

            page.evaluate(
                """
                () => {
                  const destination = document.createElement('button');
                  destination.type = 'button';
                  destination.className = 'draft-resume';
                  destination.dataset.draftIndex = '0';
                  destination.textContent = 'Open current review';
                  window.__attentionAttempts = 0;
                  window.__attentionSettled = new Promise(resolve => { window.__finishAttention = resolve; });
                  destination.onclick = async () => {
                    window.__attentionAttempts += 1;
                    await window.__attentionSettled;
                    return true;
                  };
                  document.querySelector('#my-work-list').append(destination);
                  window.__attentionRecovery = createMobileDeliveryRecovery({
                    getItems: () => [{outbox_id:'review-attention', status:'attention', title:'Review feedback'}],
                    getIndex: () => 0,
                  });
                  window.__attentionRecovery.open();
                  window.__firstAttention = window.__attentionRecovery.activate();
                  window.__secondAttention = window.__attentionRecovery.activate();
                }
                """
            )
            expect(page.locator("#mobile-delivery-recovery")).to_be_hidden()
            expect(page.locator("#my-work-list .draft-resume")).to_be_focused()
            assert page.evaluate("window.__attentionAttempts") == 1
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
            page.evaluate("window.__finishAttention()")
            page.evaluate("Promise.all([window.__firstAttention, window.__secondAttention])")
            assert page.evaluate("window.__attentionAttempts") == 1
            assert browser_errors == []
            browser.close()
    finally:
        fake.shutdown()
        fake.server_close()
        fake_thread.join(timeout=5)


@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot(
    tmp_path: Path, width: int, height: int
):
    archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
    assert len(archives) == 1, "browser job must download exactly one assembled release archive"
    original_due_dates = [item.get("due_date") for item in AVAILABLE_ISSUES]
    AVAILABLE_ISSUES[0]["due_date"] = "2026-08-19"
    AVAILABLE_ISSUES[1]["due_date"] = "2026-08-20"
    fake = FakeGiteaServer(("127.0.0.1", 0))
    fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
    fake_thread.start()
    fake_url = f"http://127.0.0.1:{fake.server_port}"
    browser_errors: list[str] = []
    failed_responses: list[str] = []

    try:
        with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
            browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
            page = browser.new_page(viewport={"width": width, "height": height})
            page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
            page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
            page.on("response", lambda response: failed_responses.append(f"{response.status} {response.url}") if response.status >= 400 else None)
            page.goto(origin + "/", wait_until="networkidle")
            page.locator('input[name="device_label"]').fill("Agenda export release phone")
            page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
            page.locator("#submit-sign-in").click()
            page.wait_for_url(origin + "/", wait_until="networkidle")

            page.locator('[data-mobile-task="queues"]').click()
            page.locator('[data-mobile-queue="agenda"]').click()
            expect(page.locator("#issue-sheet")).to_be_visible()
            page.locator("#close-issue-sheet").click()
            expect(page.locator("#issue-sheet")).to_be_hidden()
            expect(page.locator("#agenda-export")).to_be_visible()
            trigger = page.locator("#open-agenda-export")
            expect(trigger).to_be_enabled()
            bounds = trigger.bounding_box()
            assert bounds and bounds["height"] >= 44
            trigger.click()
            expect(page.locator("#agenda-export-sheet")).to_be_visible()
            expect(page.locator("#agenda-export-items input:checked")).to_have_count(2)
            page.locator("#agenda-export-items input").nth(1).uncheck()
            expect(page.locator("#agenda-export-status")).to_have_text("1 of 2 deadlines selected.")
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")

            with page.expect_download() as pending:
                page.locator("#share-agenda-export").click()
            download = pending.value
            text = Path(download.path()).read_text()
            assert download.suggested_filename.startswith("stackchain-agenda-")
            assert text.count("BEGIN:VEVENT") == 1
            assert "SUMMARY:Ship mobile capture" in text
            assert "Polish desktop filters" not in text
            expect(trigger).to_be_focused()
            assert browser_errors == []
            assert failed_responses == []
            browser.close()
    finally:
        for item, due_date in zip(AVAILABLE_ISSUES, original_due_dates):
            if due_date is None:
                item.pop("due_date", None)
            else:
                item["due_date"] = due_date
        fake.shutdown()
        fake.server_close()
        fake_thread.join(timeout=5)
