import gzip
import re
import shutil
import subprocess
from pathlib import Path

import httpx
import pytest
from starlette.requests import Request

from src import main
from src.frontend_bundle import build_frontend
from src.views import dashboard, feature_bundle, runtime_bundle, service_worker


FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
PAGE_SCRIPT = re.compile(r'<script src="(static/[^"]+\.js)"></script>')
SCRIPT_PRELOAD = re.compile(r'<link rel="preload" as="script" href="([^"]+)">')


def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
    first = build_frontend(FRONTEND)
    second = build_frontend(FRONTEND)

    assert first.runtime_name == second.runtime_name
    assert first.runtime_bytes == second.runtime_bytes
    assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
    assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
    assert PAGE_SCRIPT.findall(first.dashboard_html) == []
    assert first.dashboard_html.count("<script src=") == 1
    assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' not in first.dashboard_html
    assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
    assert first.runtime_name.startswith("runtime-")
    assert first.runtime_name.endswith(".js")
    assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024

    changed_frontend = tmp_path / "frontend"
    shutil.copytree(FRONTEND, changed_frontend)
    (changed_frontend / "session.js").write_text(
        (changed_frontend / "session.js").read_text() + "\n// changed\n"
    )

    changed = build_frontend(changed_frontend)
    assert changed.runtime_name != first.runtime_name
    assert changed.runtime_bytes != first.runtime_bytes


def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
    first = build_frontend(FRONTEND)

    assert set(first.feature_bundles) == {
        "comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
        "today-timer", "security-center", "planning",
    }
    assert first.dashboard_html.count("<script src=") == 1
    assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
    capture = first.feature_bundles["issue-capture"]
    pull_workflow = first.feature_bundles["pull-workflow"]
    assert b"function createIssueCapture" not in first.runtime_bytes
    assert b"function createIssueCapture" in capture.runtime_bytes
    assert b"function createPullSheet" not in first.runtime_bytes
    assert b"function createReviewController" not in first.runtime_bytes
    assert b"function createPullSheet" in pull_workflow.runtime_bytes
    assert b"function createReviewController" in pull_workflow.runtime_bytes
    security_center = first.feature_bundles["security-center"]
    assert b"function attachSecurityCenter" not in first.runtime_bytes
    assert b"function attachSecurityCenter" in security_center.runtime_bytes
    assert b"function createGlobalSearchController" not in first.runtime_bytes
    assert b"function createGlobalSearchController" in first.feature_bundles["today-timer"].runtime_bytes
    assert b"function createAssignAndStart" not in first.runtime_bytes
    assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
    assert b"function createQueueToday" not in first.runtime_bytes
    assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
    assert b"function createPlanToday" in first.feature_bundles["planning"].runtime_bytes
    assert b"function createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes
    assert b"function createPlanToday" not in first.runtime_bytes
    assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
    assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
    assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
    assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
    assert b"function createSignOutReview" not in first.runtime_bytes
    assert b"function createSignOutReview" in first.feature_bundles["sign-out"].runtime_bytes
    assert b"function createDetailDefer" not in first.runtime_bytes
    assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
    assert b"gitea_time_logged" not in first.runtime_bytes
    assert b"gitea_time_logged" in security_center.runtime_bytes
    # Core mobile workflows stay below 100 KiB, including synced Search, Update decisions,
    # and interruption-safe Security Center navigation.
    assert len(first.runtime_gzip_bytes) <= 100 * 1024
    assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
    assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
    assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
    assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
    assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
    assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source

    shell_block, optional_block = first.service_worker_source.split(
        "const OPTIONAL_FEATURES = [", 1
    )
    optional_block = optional_block.split("];", 1)[0]
    assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
    for name, bundle in first.feature_bundles.items():
        assert f"BASE + '{bundle.runtime_name}'" not in shell_block
        assert f"BASE + '{bundle.runtime_name}'" in optional_block

    changed_frontend = tmp_path / "frontend"
    shutil.copytree(FRONTEND, changed_frontend)
    capture_source = changed_frontend / "create-issue-sheet.js"
    capture_source.write_text(capture_source.read_text() + "\n// feature-only change\n")

    changed = build_frontend(changed_frontend)
    assert changed.runtime_name == first.runtime_name
    assert changed.feature_bundles["issue-capture"].runtime_name != capture.runtime_name

    pull_source = changed_frontend / "pull-sheet.js"
    pull_source.write_text(pull_source.read_text() + "\n// pull-workflow-only change\n")
    pull_changed = build_frontend(changed_frontend)
    assert pull_changed.runtime_name == first.runtime_name
    assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name

    security_source = changed_frontend / "security-center.js"
    security_source.write_text(security_source.read_text() + "\n// security-center-only change\n")
    security_changed = build_frontend(changed_frontend)
    assert security_changed.runtime_name == first.runtime_name
    assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name


def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
    build = build_frontend(FRONTEND)
    workspace = build.feature_bundles["today-timer"]

    assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name]
    assert build.dashboard_html.count(f'<script src="{workspace.runtime_name}"></script>') == 0
    assert len(build.runtime_gzip_bytes) <= 100 * 1024
    assert len(workspace.runtime_gzip_bytes) <= 110 * 1024

    shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
    assert f"BASE + '{workspace.runtime_name}'" not in shell_block


def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
    build = build_frontend(FRONTEND)
    bundles = {"core.js": build.runtime_bytes}
    bundles.update({f"{name}.js": bundle.runtime_bytes for name, bundle in build.feature_bundles.items()})
    for name, source in bundles.items():
        path = tmp_path / name
        path.write_bytes(source)
        result = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True)
        assert result.returncode == 0, result.stderr


@pytest.mark.anyio
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
    build = build_frontend(FRONTEND)
    capture = build.feature_bundles["issue-capture"]

    request = Request(
        {"type": "http", "method": "GET", "path": f"/{capture.runtime_name}",
         "headers": [(b"accept-encoding", b"identity")]}
    )
    response = await feature_bundle("issue-capture", capture.runtime_digest, request)

    assert response.body == capture.runtime_bytes
    assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
    with pytest.raises(Exception) as rejected:
        await feature_bundle("issue-capture", "missing", request)
    assert rejected.value.status_code == 404


def test_css_only_release_gets_a_new_shell_generation(tmp_path):
    first = build_frontend(FRONTEND)
    changed_frontend = tmp_path / "frontend"
    shutil.copytree(FRONTEND, changed_frontend)
    (changed_frontend / "dashboard.css").write_text(
        (changed_frontend / "dashboard.css").read_text() + "\n/* release change */\n"
    )

    changed = build_frontend(changed_frontend)

    assert changed.runtime_name == first.runtime_name
    assert changed.runtime_bytes == first.runtime_bytes
    assert changed.shell_digest != first.shell_digest
    assert f"const CACHE = 'stackchain-dashboard-shell-{changed.shell_digest}';" in (
        changed.service_worker_source
    )


@pytest.mark.parametrize(
    "asset",
    [
        "manifest.webmanifest",
        "icons/stackchain-192.png",
        "icons/stackchain-512.png",
        "background-issue-sync.js",
        "service-worker.js",
    ],
)
def test_every_offline_shell_asset_versions_the_shell_generation(tmp_path, asset):
    first = build_frontend(FRONTEND)
    changed_frontend = tmp_path / "frontend"
    shutil.copytree(FRONTEND, changed_frontend)
    path = changed_frontend / asset
    path.write_bytes(path.read_bytes() + b"\nrelease-change")

    changed = build_frontend(changed_frontend)

    assert changed.shell_digest != first.shell_digest


def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
    first = build_frontend(FRONTEND)
    changed_frontend = tmp_path / "frontend"
    shutil.copytree(FRONTEND, changed_frontend)
    worker = changed_frontend / "service-worker.js"
    worker.write_text(
        worker.read_text().replace(
            "const CACHE = 'stackchain-dashboard-shell-v100';",
            "const CACHE = 'stackchain-dashboard-shell-v999';",
        )
    )

    changed = build_frontend(changed_frontend)

    assert changed.shell_digest == first.shell_digest
    assert changed.service_worker_source == first.service_worker_source


@pytest.mark.anyio
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
    transport = httpx.ASGITransport(app=main.app)

    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get(
            f"/{main.FRONTEND_BUILD.runtime_name}",
            headers={"Accept-Encoding": "gzip"},
        )

    assert response.status_code == 200
    assert response.headers["content-encoding"] == "gzip"
    assert response.headers["vary"] == "Accept-Encoding"
    assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_gzip_bytes)
    assert response.content == main.FRONTEND_BUILD.runtime_bytes
    assert response.headers["cache-control"] == "public, max-age=31536000, immutable"


@pytest.mark.anyio
async def test_runtime_route_returns_the_precomputed_gzip_representation():
    build = build_frontend(FRONTEND)
    request = Request(
        {"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
         "headers": [(b"accept-encoding", b"gzip")]}
    )

    response = await runtime_bundle(build.runtime_digest, request)

    assert response.body == build.runtime_gzip_bytes
    assert response.headers["content-encoding"] == "gzip"
    assert response.headers["content-length"] == str(len(build.runtime_gzip_bytes))
    assert response.headers["vary"] == "Accept-Encoding"


@pytest.mark.anyio
async def test_runtime_requests_bypass_dynamic_gzip_compression(monkeypatch):
    class UnexpectedDynamicCompression:
        def __init__(self, *args, **kwargs):
            raise AssertionError("immutable runtime entered dynamic compression")

    monkeypatch.setattr("src.compression.GZipResponder", UnexpectedDynamicCompression)
    transport = httpx.ASGITransport(app=main.app)

    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get(
            f"/{main.FRONTEND_BUILD.runtime_name}",
            headers={"Accept-Encoding": "gzip"},
        )

    assert response.status_code == 200
    assert response.headers["content-encoding"] == "gzip"


@pytest.mark.anyio
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
    transport = httpx.ASGITransport(app=main.app)

    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get(
            f"/{main.FRONTEND_BUILD.runtime_name}",
            headers={"Accept-Encoding": accept_encoding},
        )

    assert response.status_code == 200
    assert "content-encoding" not in response.headers
    assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_bytes)
    assert response.content == main.FRONTEND_BUILD.runtime_bytes


@pytest.mark.anyio
async def test_dashboard_shell_is_compressed_without_losing_response_policies():
    transport = httpx.ASGITransport(app=main.app)

    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        page = await client.get("/", headers={"Accept-Encoding": "gzip"})
        stylesheet = await client.get(
            "/static/dashboard.css", headers={"Accept-Encoding": "gzip"}
        )
        health = await client.get("/healthz", headers={"Accept-Encoding": "gzip"})

    assert page.headers["content-encoding"] == "gzip"
    assert page.headers["cache-control"] == "no-cache"
    assert page.headers["content-security-policy"]
    assert main.FRONTEND_BUILD.runtime_name in page.text
    assert stylesheet.headers["content-encoding"] == "gzip"
    assert "mobile-task-dock" in stylesheet.text
    assert health.json() == {"service": "stackchain-dashboard", "status": "ok"}
    assert "content-encoding" not in health.headers


def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules():
    build = build_frontend(FRONTEND)

    assert f"BASE + '{build.runtime_name}'" in build.service_worker_source
    for source in build.page_sources:
        if f"importScripts(BASE + '{source}')" in build.service_worker_source:
            continue
        assert f"BASE + '{source}'" not in build.service_worker_source


@pytest.mark.anyio
async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate():
    build = build_frontend(FRONTEND)
    request = Request(
        {"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
         "headers": [(b"accept-encoding", b"identity")]}
    )

    html = await dashboard()
    bundle = await runtime_bundle(build.runtime_digest, request)
    worker = await service_worker()

    assert build.runtime_name in html
    assert bundle.body == build.runtime_bytes
    assert bundle.headers["cache-control"] == "public, max-age=31536000, immutable"
    assert worker.headers["cache-control"] == "no-cache"
    assert build.runtime_name.encode() in worker.body
