from __future__ import annotations

import json
import os
import socket
import ssl
import subprocess
import sys
import tarfile
import threading
import time
import urllib.request
from contextlib import contextmanager
from pathlib import Path

import pytest

if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
    pytest.skip("release artifact browser journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import Page, expect, sync_playwright

from fake_gitea import FakeGiteaServer


ROOT = Path(__file__).resolve().parents[2]
TITLE = "Offline artifact journey 863."
BODY = "Captured on a phone, retained offline, delivered exactly once."
ACCESS_TOKEN = "artifact-browser-access-token-863"


def free_port() -> int:
    with socket.socket() as sock:
        sock.bind(("127.0.0.1", 0))
        return int(sock.getsockname()[1])


def wait_for_health(url: str, process: subprocess.Popen[str]) -> None:
    deadline = time.monotonic() + 20
    tls = ssl._create_unverified_context()
    while time.monotonic() < deadline:
        if process.poll() is not None:
            stdout, stderr = process.communicate()
            raise AssertionError(f"release server exited early\nstdout:\n{stdout}\nstderr:\n{stderr}")
        try:
            with urllib.request.urlopen(url, timeout=0.5, context=tls) as response:
                if response.status == 200:
                    return
        except OSError:
            time.sleep(0.05)
    raise AssertionError("release server did not become healthy")


@contextmanager
def release_server(archive: Path, tmp_path: Path, gitea_url: str):
    release_root = tmp_path / "release"
    release_root.mkdir()
    with tarfile.open(archive, "r:gz") as bundle:
        bundle.extractall(release_root, filter="data")
    assert (release_root / "release-manifest.json").is_file()
    assert not (release_root / "tests").exists(), "journey must run the packaged artifact, not checkout code"

    port = free_port()
    state_dir = tmp_path / "state"
    state_dir.mkdir()
    key_file = tmp_path / "localhost.key"
    certificate_file = tmp_path / "localhost.crt"
    subprocess.run(
        [
            "openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
            "-keyout", str(key_file), "-out", str(certificate_file), "-days", "1",
            "-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1",
        ],
        check=True,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    origin = f"https://127.0.0.1:{port}"
    env = {
        **os.environ,
        "PYTHONPATH": str(release_root),
        "GITEA_URL": gitea_url,
        "GITEA_TOKEN": "deterministic-e2e-token",
        "STACKCHAIN_DASHBOARD_AUTH_MODE": "operator",
        "STACKCHAIN_DASHBOARD_ACCESS_TOKEN": ACCESS_TOKEN,
        "STACKCHAIN_DASHBOARD_SESSION_SECRET": "artifact-browser-independent-session-secret-863",
        "STACKCHAIN_UNFILED_DRAFT_ENCRYPTION_KEY": (
            "ZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGQ="
        ),
        "STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN": origin,
        "STACKCHAIN_STATE_DIR": str(tmp_path / "state"),
    }
    process = subprocess.Popen(
        [
            sys.executable, "-m", "uvicorn", "src.main:app",
            "--host", "127.0.0.1", "--port", str(port),
            "--ssl-keyfile", str(key_file), "--ssl-certfile", str(certificate_file),
        ],
        cwd=release_root,
        env=env,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    try:
        wait_for_health(origin + "/healthz", process)
        yield origin
    finally:
        process.terminate()
        try:
            process.communicate(timeout=10)
        except subprocess.TimeoutExpired:
            process.kill()
            process.communicate()


def indexed_issue_records(page: Page) -> list[dict]:
    return page.evaluate(
        """async () => {
          const db = await new Promise((resolve, reject) => {
            const request = indexedDB.open('stackchain-background-outbox-v1', 1);
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
          });
          return await new Promise((resolve, reject) => {
            const request = db.transaction('issues', 'readonly').objectStore('issues').getAll();
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
          });
        }"""
    )


def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_reload(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] = []
    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"])
            context = browser.new_context(
                viewport={"width": 390, "height": 844},
                service_workers="allow",
                ignore_https_errors=True,
            )
            context.add_init_script("""
              class DeterministicSpeechRecognition {
                start() { window.__voiceRecognition = this; }
                stop() { if (this.onend) this.onend(); }
                abort() { if (this.onend) this.onend(); }
              }
              window.SpeechRecognition = DeterministicSpeechRecognition;
            """)
            page = context.new_page()
            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")
            expect(page.locator("#sign-in")).to_be_visible()
            page.locator('input[name="device_label"]').fill("Release journey phone")
            page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
            page.locator("#submit-sign-in").click()
            page.wait_for_url(origin + "/", wait_until="networkidle")
            new_action = page.locator('[data-mobile-task="new"]')
            expect(new_action).to_be_visible()
            service_worker_ready = page.evaluate(
                """() => Promise.race([
                  navigator.serviceWorker.ready.then(() => true),
                  new Promise(resolve => setTimeout(() => resolve(false), 10000)),
                ])"""
            )
            assert service_worker_ready, (
                "release artifact did not register its service worker; browser errors=" + repr(browser_errors)
                + "; failed responses=" + repr(failed_responses)
            )
            if not page.evaluate("() => navigator.serviceWorker.controller !== null"):
                page.reload(wait_until="networkidle")
                new_action = page.locator('[data-mobile-task="new"]')
            assert page.evaluate("() => navigator.serviceWorker.controller !== null")

            new_action.click()
            expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
            assert page.evaluate("document.activeElement?.id") == "cancel-new-issue"
            assert page.evaluate("document.querySelector('main').inert") is True
            page.keyboard.press("Shift+Tab")
            assert page.evaluate("document.activeElement.closest('#create-issue-sheet')?.id") == "create-issue-sheet"
            page.keyboard.press("Escape")
            expect(page.locator("#create-issue-sheet")).not_to_have_class("create-issue-sheet open")
            assert page.evaluate("document.activeElement?.dataset.mobileTask") == "new"
            assert page.evaluate("document.querySelector('main').inert") is False
            new_action.click()
            expect(page.locator("#create-issue-sheet")).to_have_class("create-issue-sheet open")
            assert page.evaluate("document.activeElement?.id") == "cancel-new-issue"
            create_navigation = page.locator(".mobile-create-issue-nav")
            expect(create_navigation).to_be_visible()
            create_navigation_buttons = create_navigation.locator("button")
            assert create_navigation_buttons.count() == 3
            for control in create_navigation_buttons.all():
                bounds = control.bounding_box()
                assert bounds and bounds["height"] >= 44
            expect(create_navigation.locator('[data-create-issue-section="describe"]')).to_have_attribute(
                "aria-current", "location"
            )
            expect(create_navigation.locator('[data-create-issue-section="file"]')).to_have_attribute(
                "aria-disabled", "true"
            )
            create_navigation.locator('[data-create-issue-section="evidence"]').click()
            expect(create_navigation.locator('[data-create-issue-section="evidence"]')).to_have_attribute(
                "aria-current", "location"
            )
            for control in page.locator(".photo-evidence-actions .issue-attachment-trigger").all():
                bounds = control.bounding_box()
                assert bounds and bounds["height"] >= 44
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
            expect(page.locator("#voice-issue-capture")).to_be_visible()
            page.locator("#start-voice-issue-capture").click()
            page.evaluate("""([title, body]) => {
              const result = [{ transcript: title + ' ' + body }];
              result.isFinal = true;
              window.__voiceRecognition.onresult({ results: [result] });
            }""", [TITLE, BODY])
            expect(page.locator("#voice-issue-review")).to_be_visible()
            expect(page.locator("#append-voice-issue-transcript")).to_have_text("Use transcript")
            page.locator("#append-voice-issue-transcript").click()
            expect(page.locator("#create-issue-title")).to_have_value(TITLE)
            expect(page.locator("#create-issue-body")).to_have_value(BODY)
            page.locator("#file-new-issue").click()
            expect(page.locator("#create-issue-filing")).to_be_visible()
            expect(create_navigation.locator('[data-create-issue-section="file"]')).to_have_attribute(
                "aria-current", "location"
            )
            expect(create_navigation.locator('[data-create-issue-section="file"]')).not_to_have_attribute(
                "aria-disabled", "true"
            )
            page.locator("#create-issue-repository").select_option("acme/mobile")
            expect(page.locator("#submit-new-issue")).to_be_enabled()

            # Page offline emulation can leave service-worker requests connected.
            # Abort the mutation at the browser boundary too, matching a real
            # transport outage without poisoning server-side idempotency state.
            context.route("**/api/v1/repos/acme/mobile/issues", lambda route: route.abort())
            context.set_offline(True)
            page.locator("#submit-new-issue").click()
            expect(page.locator("#issue-filing-review")).to_be_visible()
            page.locator("#confirm-issue-filing").click()
            page.wait_for_timeout(1_000)
            filing_statuses = page.locator(
                "#my-work-action-status, #create-issue-status, #issue-filing-review-status"
            ).all_inner_texts()
            assert any(
                marker in " ".join(filing_statuses).lower()
                for marker in ("queued", "saved for", "background delivery")
            ), (filing_statuses, browser_errors, failed_responses)

            local_record = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
            assert [(item["title"], item["body"], item["repository"]) for item in local_record["items"]] == [
                (TITLE, BODY, "acme/mobile")
            ]
            durable = indexed_issue_records(page)
            assert [(item["title"], item["body"], item["repository"]) for item in durable] == [
                (TITLE, BODY, "acme/mobile")
            ]
            operation_id = durable[0]["operationId"]
            assert operation_id and operation_id == local_record["items"][0]["operationId"]
            assert fake.created_issues == []

            page.reload(wait_until="domcontentloaded")
            expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
            durable_after_reload = indexed_issue_records(page)
            assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
            assert durable_after_reload[0]["operationId"] == operation_id
            assert fake.created_issues == []

            browser_errors.clear()  # Chromium reports expected network errors while the context is offline.
            failed_responses.clear()
            fake.issue_creation_enabled = True
            fake.issue_creation_ready.set()
            context.unroute("**/api/v1/repos/acme/mobile/issues")
            context.set_offline(False)
            page.evaluate("window.dispatchEvent(new Event('online'))")
            # Containerized Actions runners can take longer than a local browser to wake the
            # service worker and complete its identity handshake after reconnect.
            for _ in range(160):
                if fake.created_issues:
                    break
                page.wait_for_timeout(250)
            local_debug = page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')")
            durable_debug = indexed_issue_records(page)
            assert fake.created_issues == [
                {"title": TITLE, "body": BODY, "assignee": "timmy"}
            ], (
                f"url={page.url} ready={page.evaluate('document.readyState')} "
                f"status={page.locator('#my-work-action-status').inner_text()!r} "
                f"errors={browser_errors[-5:]!r} responses={failed_responses[-10:]!r} "
                f"local={local_debug!r} durable={durable_debug!r} "
                f"requests={fake.requests[-20:]!r}"
            )
            # Deferred workspace startup can report its already-issued offline request
            # after the pre-reconnect clear; retain every non-offline browser error.
            browser_errors[:] = [
                error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
            ]

            for _ in range(40):
                durable_completion = indexed_issue_records(page)
                if durable_completion and all(item.get("status") == "sent" for item in durable_completion):
                    break
                page.wait_for_timeout(100)
            assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
            page.reload(wait_until="networkidle")
            page.evaluate("window.dispatchEvent(new Event('online'))")
            for _ in range(40):
                completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
                if completed_local["items"] == []:
                    break
                page.wait_for_timeout(100)
            assert completed_local["items"] == []
            page.wait_for_timeout(500)
            assert len(fake.created_issues) == 1
            assert browser_errors == []
            assert failed_responses == []
            assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
            browser.close()
    finally:
        fake.issue_creation_ready.set()
        fake.shutdown()
        fake.server_close()
        fake_thread.join(timeout=5)
