"""Browser tests for the Context Gateway per-type list sections (Q-PR4, #826).

Pin behavioral correctness for the langchange staleness audit follow-up
that PR #824 (Q-PR1) intentionally left narrow. The overview-level
``langchange`` listener PR #824 added is now extended to also cover the
three per-type sections (``settings-ctx-skills`` / ``-commands`` /
``-agents``) where five other inline-``t()`` regions live:

* ``_ctxScopeBadges`` — non-cwd scope badges (experimental, missing)
* ``renderRuntimeBadges`` — per-runtime status labels on each card
* ``_ctxRefreshSectionState`` — runtime-only banner above the list
* ``renderImportResult`` — post-Import status receipt
* ``renderDroppedChips`` — Dropped-field chips inside the Diff pane

The harness mirrors ``test_context_gateway_overview.py``: lifespan is off
(see ``conftest.py`` docstring), every ``/api/**`` call is intercepted via
``page.route()``, and the SPA boots from disk.
"""

from __future__ import annotations

import json

import pytest

from .conftest import install_default_stubs

pytestmark = pytest.mark.browser


def _open_skills_list(page) -> None:
    """Navigate to the Skills sub-section of Context Gateway.

    Same activation pattern as ``_open_context_gateway`` in the overview
    suite, but lands on ``settings-ctx-skills``. Waits on the populated
    list (``.ctx-scope-group`` rendered) rather than a visibility check —
    the section may carry stale ``.active`` from a previous test in the
    same browser context.
    """
    page.evaluate("() => activateTab('settings')")
    page.evaluate("() => switchSettingsSection('ctx-skills')")
    page.wait_for_function(
        "() => document.querySelectorAll('#ctx-skills-list .ctx-scope-group').length > 0",
        timeout=5_000,
    )


def _open_agents_list(page) -> None:
    """Navigate to the Agents sub-section of Context Gateway (ADR-0022 PR4 chips
    live only on agents + commands, never skills)."""
    page.evaluate("() => activateTab('settings')")
    page.evaluate("() => switchSettingsSection('ctx-agents')")
    page.wait_for_function(
        "() => document.querySelectorAll('#ctx-agents-list .ctx-scope-group').length > 0",
        timeout=5_000,
    )


def _show_all_projects(page, type_: str = "skills") -> None:
    """Enable the rank-2c "Show all projects" toggle so non-active scope
    accordions render.

    Artifact sections default to the active project only (the Projects portal
    owns the full roster), so any assertion on a non-active scope's accordion
    must first opt back into the full list. Mirrors the portal suite's
    ``showUninitialized`` helper for the rank-3 hide-uninit toggle. Waits for
    the re-render to paint more than one group so the caller's per-scope
    locators don't race the ``loadCtxList`` refetch the toggle triggers.
    """
    page.locator(f"#ctx-{type_}-show-all").check()
    page.wait_for_function(
        f"() => document.querySelectorAll('#ctx-{type_}-list .ctx-scope-group').length > 1",
        timeout=5_000,
    )


# Single-scope CWD payload with one out-of-sync canonical skill (so
# ``renderRuntimeBadges`` has something to label) plus a non-cwd scope
# carrying the ``missing`` flag (so ``_ctxScopeBadges`` renders).
_CWD_PROJECTS_WITH_NON_CWD_MISSING = {
    "scopes": [
        {
            "scope_id": "cwd-scope",
            "label": "cwd",
            "root": "/srv/cwd",
            "tier": "user",
            "sources": ["server-cwd"],
            "experimental": False,
            "missing": False,
            "counts": {"skills": 1, "commands": 0, "agents": 0},
        },
        {
            "scope_id": "missing-scope",
            "label": "old-project",
            "root": "/srv/old",
            "tier": "user",
            "sources": ["history"],
            "experimental": False,
            "missing": True,
            "counts": {"skills": 0, "commands": 0, "agents": 0},
        },
    ]
}


# Active Server-CWD scope plus a second *live* project (not missing) so the
# rank-2c active-only default has a real non-active accordion to hide.
_CWD_PROJECTS_WITH_SECOND_LIVE = {
    "scopes": [
        {
            "scope_id": "cwd-scope",
            "label": "cwd",
            "root": "/srv/cwd",
            "tier": "user",
            "sources": ["server-cwd"],
            "experimental": False,
            "missing": False,
            "counts": {"skills": 1, "commands": 0, "agents": 0},
        },
        {
            "scope_id": "other-proj",
            "label": "other-project",
            "root": "/srv/other",
            "tier": "user",
            "sources": ["history"],
            "experimental": False,
            "missing": False,
            "counts": {"skills": 1, "commands": 0, "agents": 0},
        },
    ]
}


# Items shape consumed by ``_ctxRenderItemsHtml`` and ``renderRuntimeBadges``.
_CWD_SKILLS_ITEMS = {
    "skills": [
        {
            "name": "demo-skill",
            "canonical_path": "/srv/cwd/.memtomem/skills/demo-skill.md",
            "runtimes": [{"runtime": "claude_skills", "status": "in sync"}],
        }
    ],
    "scanned_dirs": ["/srv/cwd/.claude/skills/"],
}


# Runtime-only payload: one item with no canonical_path so
# ``_ctxRefreshSectionState`` inserts the banner.
_CWD_SKILLS_RUNTIME_ONLY = {
    "skills": [
        {
            "name": "drift-skill",
            "canonical_path": "",
            "runtimes": [
                {
                    "runtime": "claude_skills",
                    "status": "missing canonical",
                    "runtime_path": "/srv/cwd/.claude/skills/drift-skill.md",
                    "runtime_content": "# Drift skill\n",
                }
            ],
        }
    ],
    "scanned_dirs": ["/srv/cwd/.claude/skills/"],
}


# Diff-endpoint payload for the canonical detail's Diff tab. Mirrors the real
# wire shape: per-runtime entries carry runtime/status/runtime_content only
# (no ``dropped_fields`` — /diff never sends it, #1247 id 35). The localized
# status badge is the langchange marker the Q-PR4 test keys on.
_DIFF_OUT_OF_SYNC = {
    "canonical_content": "name: demo\n",
    "runtimes": [
        {
            "runtime": "claude_skills",
            "status": "out of sync",
            "runtime_path": "/srv/cwd/.claude/skills/demo-skill.md",
            "runtime_content": "name: demo\nextra: yes\n",
        }
    ],
}


# Runtime-only diff payload (no canonical_content; triggers the
# ``_ctxLoadRuntimeOnlyDetail`` render).
_DIFF_RUNTIME_ONLY = {
    "canonical_content": "",
    "runtimes": [
        {
            "runtime": "claude_skills",
            "status": "missing canonical",
            "runtime_path": "/srv/cwd/.claude/skills/drift-skill.md",
            "runtime_content": "# Drift skill\n",
        }
    ],
}


def _stub_projects(page, payload):
    page.route(
        "**/api/context/projects**",
        lambda r: r.fulfill(status=200, content_type="application/json", body=json.dumps(payload)),
    )


def _stub_skills(page, payload):
    page.route(
        "**/api/context/skills**",
        lambda r: r.fulfill(status=200, content_type="application/json", body=json.dumps(payload)),
    )


def test_q_pr4_langchange_rerenders_scope_badge_inline_text(page, mm_web_url: str) -> None:
    """``_ctxScopeBadges`` renders the ``scope_missing`` chip via inline
    ``t()`` in innerHTML; without the listener's ``loadCtxList`` re-issue
    a language toggle would leave it in the prior locale until the user
    triggered a list reload through some other path.

    EN ``(missing)`` → KO ``(없음)``. The ``scope_experimental`` chip
    shares the same English/Korean string so it can't carry this
    assertion — ``scope_missing`` is the canary."""
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    page.goto(mm_web_url)
    _open_skills_list(page)
    # The missing scope is non-active, so it's hidden until "Show all
    # projects" is enabled (rank 2c).
    _show_all_projects(page, "skills")

    badge = page.locator(
        "#ctx-skills-list "
        "details[data-scope-id='missing-scope'] "
        ".ctx-scope-badge.ctx-scope-badge--missing"
    )
    pre = (badge.text_content() or "").strip()
    assert pre == "(missing)", f"EN scope_missing badge should be '(missing)', got {pre!r}"

    page.evaluate("async () => { await I18N.setLang('ko'); }")
    page.wait_for_function(
        "() => {"
        "  const el = document.querySelector("
        '    \'#ctx-skills-list details[data-scope-id="missing-scope"] '
        ".ctx-scope-badge--missing');"
        "  return el && el.textContent.trim() === '(없음)';"
        "}",
        timeout=3_000,
    )
    post = (
        page.locator(
            "#ctx-skills-list details[data-scope-id='missing-scope'] "
            ".ctx-scope-badge.ctx-scope-badge--missing"
        ).text_content()
        or ""
    ).strip()
    assert post == "(없음)", f"KO scope_missing badge should be '(없음)', got {post!r}"
    # Symmetric negative: the English literal must not linger.
    assert "(missing)" not in post, f"EN literal must not survive KO toggle: {post!r}"


def test_artifact_list_defaults_to_active_scope_only(page, mm_web_url: str) -> None:
    """rank 2c: an artifact section paints ONLY the active project's accordion
    by default — not the full roster the Projects portal already owns.

    With two live scopes (active Server-CWD + a second project), the cold
    list renders exactly one ``.ctx-scope-group`` (the active one), the
    non-active scope's accordion is absent, and the "Show all projects"
    toggle is present but unchecked.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_SECOND_LIVE)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    page.goto(mm_web_url)
    _open_skills_list(page)

    groups = page.locator("#ctx-skills-list .ctx-scope-group")
    assert groups.count() == 1, (
        f"Default artifact list must render only the active scope, got {groups.count()} groups"
    )
    assert page.locator("#ctx-skills-list details[data-scope-id='cwd-scope']").count() == 1, (
        "The active (Server-CWD) scope accordion must be the one rendered"
    )
    assert page.locator("#ctx-skills-list details[data-scope-id='other-proj']").count() == 0, (
        "A non-active scope accordion must be hidden until 'Show all projects'"
    )

    toggle = page.locator("#ctx-skills-show-all")
    assert toggle.count() == 1, "The 'Show all projects' toggle must render with >1 scope"
    assert not toggle.is_checked(), "The toggle defaults to off (active project only)"
    # The ``{n}`` count interpolates the total scope count (2 here) so a broken
    # i18n key or replace would surface as a literal ``{n}`` / missing number.
    label = page.locator(".ctx-list-show-all[data-type='skills'] span")
    assert "(2)" in (label.text_content() or ""), (
        f"Toggle label must interpolate the scope count, got {label.text_content()!r}"
    )


def test_show_all_projects_toggle_reveals_all_scopes(page, mm_web_url: str) -> None:
    """rank 2c: enabling "Show all projects" re-renders the full roster — every
    scope accordion, including non-active ones, becomes visible again.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_SECOND_LIVE)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    page.goto(mm_web_url)
    _open_skills_list(page)
    assert page.locator("#ctx-skills-list .ctx-scope-group").count() == 1

    # ``_show_all_projects`` checks the toggle and waits for >1 group to paint.
    _show_all_projects(page, "skills")

    assert page.locator("#ctx-skills-list .ctx-scope-group").count() == 2, (
        "Show all projects must reveal every scope accordion"
    )
    assert page.locator("#ctx-skills-list details[data-scope-id='cwd-scope']").count() == 1, (
        "Active scope still present after revealing all"
    )
    assert page.locator("#ctx-skills-list details[data-scope-id='other-proj']").count() == 1, (
        "Previously hidden non-active scope is now visible"
    )


def test_single_scope_install_has_no_show_all_toggle(page, mm_web_url: str) -> None:
    """rank 2c: a Server-CWD-only install (one scope) has nothing to collapse,
    so the "Show all projects" toggle is suppressed — it would be a dead
    checkbox. The default ``install_default_stubs`` payload is exactly this
    single-scope shape.
    """
    install_default_stubs(page)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    page.goto(mm_web_url)
    _open_skills_list(page)

    assert page.locator("#ctx-skills-list .ctx-scope-group").count() == 1
    assert page.locator("#ctx-skills-show-all").count() == 0, (
        "Single-scope installs must not render the show-all toggle"
    )


def test_q_pr4_langchange_rerenders_runtime_badge_label(page, mm_web_url: str) -> None:
    """``renderRuntimeBadges`` resolves status text via ``_ctxStatusText``
    in innerHTML — no ``data-i18n`` walker reaches it. The listener's
    ``loadCtxList`` re-issue must rebuild the badges so EN ``In sync`` →
    KO ``동기화됨``.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    page.goto(mm_web_url)
    _open_skills_list(page)

    # Wait for the cwd group's items to load (lazy fetch on the open group).
    page.wait_for_function(
        "() => document.querySelectorAll("
        '  \'#ctx-skills-list details[data-scope-id="cwd-scope"] '
        ".ctx-runtime-badge').length > 0",
        timeout=5_000,
    )
    badge = page.locator(
        "#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-runtime-badge"
    ).first
    pre_text = (badge.text_content() or "").strip()
    assert "In sync" in pre_text, f"EN runtime badge should contain 'In sync', got {pre_text!r}"

    page.evaluate("async () => { await I18N.setLang('ko'); }")
    page.wait_for_function(
        "() => {"
        "  const els = Array.from(document.querySelectorAll("
        '    \'#ctx-skills-list details[data-scope-id="cwd-scope"] '
        ".ctx-runtime-badge'));"
        "  return els.some(el => el.textContent.includes('동기화됨'));"
        "}",
        timeout=3_000,
    )
    post_text = (
        page.locator(
            "#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-runtime-badge"
        ).first.text_content()
        or ""
    ).strip()
    assert "동기화됨" in post_text, f"KO runtime badge should contain '동기화됨', got {post_text!r}"
    # Negative: EN literal must not linger after toggle.
    assert "In sync" not in post_text, f"EN 'In sync' must not survive KO toggle: {post_text!r}"


def test_context_list_card_renders_project_local_tier_badge_with_annotation(
    page, mm_web_url: str
) -> None:
    """Context artifact cards render the literal tier token and the
    project_local zero-runtime-fan-out annotation inline with the name.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(
        page,
        {
            "skills": [
                {
                    "name": "draft-skill",
                    "canonical_path": ".memtomem/skills.local/draft-skill",
                    "target_scope": "project_local",
                    "runtimes": [],
                }
            ],
            "scanned_dirs": [],
        },
    )
    page.goto(mm_web_url)
    _open_skills_list(page)

    card_name = page.locator("#ctx-skills-list .ctx-card-name").first
    text = card_name.text_content() or ""
    assert "project_local" in text
    assert "no runtime fan-out" in text
    assert card_name.locator(".badge-tier--project_local").count() == 1


# ADR-0022 PR4: cwd scope carrying one versioned agent. ``counts.agents >= 1``
# so the agents section's cwd group lazily fetches the list.
_CWD_PROJECTS_AGENTS = {
    "scopes": [
        {
            "scope_id": "cwd-scope",
            "label": "cwd",
            "root": "/srv/cwd",
            "tier": "user",
            "sources": ["server-cwd"],
            "experimental": False,
            "missing": False,
            "counts": {"skills": 0, "commands": 0, "agents": 1},
        },
    ]
}

# Enriched agents list payload as ``?include=versions`` would return it: one
# canonical agent with two label pointers landing on frozen versions.
_CWD_AGENTS_WITH_LABELS = {
    "agents": [
        {
            "name": "reviewer",
            "canonical_path": "/srv/cwd/.memtomem/agents/reviewer/agent.md",
            "target_scope": "user",
            "runtimes": [{"runtime": "claude_agents", "status": "in sync"}],
            "versions": {
                "labels": {"production": "v2", "staging": "v1"},
                "count": 2,
                "versionable": True,
                "migrate_required": False,
            },
        }
    ],
    "scanned_dirs": ["/srv/cwd/.claude/agents/"],
}


def test_pr4_list_card_renders_label_chips_and_requests_include_versions(
    page, mm_web_url: str
) -> None:
    """End-to-end pin for ADR-0022 PR4: the agents list is fetched with
    ``?include=versions`` and the per-item label map renders read-only
    ``production → v2`` chips on the card (with SR parity via aria-label).
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_AGENTS)

    agents_calls: list[str] = []

    def _agents_handler(route):
        agents_calls.append(route.request.url)
        route.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps(_CWD_AGENTS_WITH_LABELS),
        )

    page.route("**/api/context/agents**", _agents_handler)
    page.goto(mm_web_url)
    _open_agents_list(page)

    page.wait_for_selector(
        "#ctx-agents-list details[data-scope-id='cwd-scope'] "
        ".ctx-card[data-name='reviewer'] .ctx-card-label-chip",
        timeout=5_000,
    )
    chips = page.locator(
        "#ctx-agents-list details[data-scope-id='cwd-scope'] "
        ".ctx-card[data-name='reviewer'] .ctx-card-label-chip"
    )
    assert chips.count() == 2, f"expected 2 label chips, got {chips.count()}"

    prod = page.locator(
        "#ctx-agents-list .ctx-card[data-name='reviewer'] "
        ".ctx-card-label-chip[data-label='production']"
    )
    prod_text = (prod.text_content() or "").replace(" ", "")
    assert "production" in prod_text and "v2" in prod_text, (
        f"production chip should read 'production → v2', got {prod_text!r}"
    )

    # The enrichment must actually be requested for the agents list — the chips
    # have no other data source.
    assert any("include=versions" in u for u in agents_calls), (
        f"agents list must be fetched with ?include=versions; got {agents_calls!r}"
    )

    # SR parity: the clickable card echoes the pointers into its aria-label
    # (which overrides the visible chip text for assistive tech).
    aria = (
        page.locator("#ctx-agents-list .ctx-card[data-name='reviewer']").get_attribute("aria-label")
        or ""
    )
    assert "production" in aria and "v2" in aria, (
        f"card aria-label should echo the label pointers, got {aria!r}"
    )


def test_context_list_non_shared_tier_click_threads_target_scope(page, mm_web_url: str) -> None:
    """Card click on a non-shared tier hits ``?target_scope=...`` (P1 #940 r3).

    Item-level routes now accept ``target_scope`` (skills/agents/commands
    read/diff/rendered honor every tier; create/update/delete/sync/import
    reject non-shared with HTTP 400 via ``_reject_non_shared_write``). This
    pins the round-trip: a click on a project_local card MUST fire
    ``GET /api/context/skills/draft-skill?target_scope=project_local`` so the
    backend opens the project_local canonical (not the same-name shared one).
    """
    install_default_stubs(page)
    # The shared ``_stub_*`` helpers register patterns without trailing ``**``
    # so they miss URLs that carry ``?target_scope=...``. This test triggers
    # a tier switch which adds that query string, so register wider patterns
    # locally; last-route-wins puts these ahead of the catch-all.
    _skills_payload = {
        "skills": [
            {
                "name": "draft-skill",
                "canonical_path": ".memtomem/skills.local/draft-skill",
                "target_scope": "project_local",
                "runtimes": [],
            }
        ],
        "scanned_dirs": [],
    }
    page.route(
        "**/api/context/projects**",
        lambda r: r.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps(_CWD_PROJECTS_WITH_NON_CWD_MISSING),
        ),
    )
    page.route(
        "**/api/context/skills**",
        lambda r: r.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps(_skills_payload),
        ),
    )

    detail_calls: list[str] = []

    def _on_detail(route):
        detail_calls.append(route.request.url)
        # Return a minimal valid detail payload so loadCtxDetail doesn't error.
        route.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps(
                {"name": "draft-skill", "content": "# draft\n", "mtime_ns": "0", "files": []}
            ),
        )

    page.route("**/api/context/skills/draft-skill**", _on_detail)

    page.goto(mm_web_url)
    _open_skills_list(page)

    page.locator("#ctx-control-bar .ctx-tier-filter button[data-scope='project_local']").click()
    page.wait_for_function(
        "() => {"
        "  const b = document.querySelector("
        "    '#ctx-control-bar .ctx-tier-filter button[data-scope=\"project_local\"]');"
        "  return b && b.classList.contains('active');"
        "}",
        timeout=3_000,
    )
    page.wait_for_selector(
        "#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-card",
        timeout=5_000,
    )

    card = page.locator("#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-card").first
    card.click()

    page.wait_for_function(
        "() => window.__lastDetailUrl !== undefined || true",
        timeout=500,
    )
    # The card MUST be clickable on non-shared tiers — readonly-card workaround
    # is gone now that routes honor target_scope.
    card_classes = card.get_attribute("class") or ""
    assert "ctx-card--readonly" not in card_classes, (
        f"non-shared-tier card should be clickable now (#940 r3), got class={card_classes!r}"
    )
    # The detail fetch fires with the tier appended.
    assert len(detail_calls) == 1, f"expected 1 detail call, got {detail_calls}"
    assert "target_scope=project_local" in detail_calls[0], (
        f"detail URL should carry target_scope=project_local, got {detail_calls[0]}"
    )


def test_q_pr4_langchange_rerenders_runtime_only_banner(page, mm_web_url: str) -> None:
    """``_ctxRefreshSectionState`` writes the runtime-only banner via
    ``textContent`` (not innerHTML), but the staleness mechanism is the
    same — ``I18N.applyDOM`` doesn't walk it. The listener's
    ``loadCtxList`` re-issue must rebuild the banner with the new locale.
    """
    install_default_stubs(page)
    # Single cwd-only scope with a runtime-only item triggers the banner.
    _stub_projects_wide(
        page,
        {
            "scopes": [
                {
                    "scope_id": "cwd-scope",
                    "label": "cwd",
                    "root": "/srv/cwd",
                    "tier": "user",
                    "sources": ["server-cwd"],
                    "experimental": False,
                    "missing": False,
                    "counts": {"skills": 1, "commands": 0, "agents": 0},
                },
            ],
        },
    )
    _stub_skills_wide(page, _CWD_SKILLS_RUNTIME_ONLY)
    page.goto(mm_web_url)
    _open_skills_list(page)

    page.wait_for_selector(
        "#ctx-skills-list .ctx-runtime-only-banner",
        timeout=5_000,
    )
    banner_pre = (
        page.locator("#ctx-skills-list .ctx-runtime-only-banner").text_content() or ""
    ).strip()
    # The banner copy is a templated string; pin the EN-only token
    # ``Click Import`` and the KO-only token ``가져오기`` to keep the
    # assertion robust against count/dir interpolation. (Copy-polish pass
    # reworded the KO body to ``가져오기를 누르거나`` — the KO marker is now
    # the Import noun, not the old ``눌러`` verb form.)
    assert "Click Import" in banner_pre, (
        f"EN runtime-only banner should contain 'Click Import': {banner_pre!r}"
    )

    page.evaluate("async () => { await I18N.setLang('ko'); }")
    page.wait_for_function(
        "() => {"
        "  const el = document.querySelector('#ctx-skills-list .ctx-runtime-only-banner');"
        "  return el && el.textContent.includes('가져오기');"
        "}",
        timeout=3_000,
    )
    banner_post = (
        page.locator("#ctx-skills-list .ctx-runtime-only-banner").text_content() or ""
    ).strip()
    assert "가져오기" in banner_post, (
        f"KO runtime-only banner should contain '가져오기': {banner_post!r}"
    )
    assert "Click Import" not in banner_post, (
        f"EN literal must not survive KO toggle: {banner_post!r}"
    )


def test_missing_canonical_remediation_lists_project_shared_cli_commands(
    page, mm_web_url: str
) -> None:
    """Runtime-only entries must show the exact CLI bootstrap path in the
    default project_shared tier, not just a generic "missing canonical" hint.
    """
    install_default_stubs(page)
    _stub_projects_wide(
        page,
        {
            "scopes": [
                {
                    "scope_id": "cwd-scope",
                    "label": "cwd",
                    "root": "/srv/cwd",
                    "tier": "user",
                    "sources": ["server-cwd"],
                    "experimental": False,
                    "missing": False,
                    "counts": {"skills": 1, "commands": 0, "agents": 0},
                },
            ],
        },
    )
    _stub_skills_wide(page, _CWD_SKILLS_RUNTIME_ONLY)
    page.goto(mm_web_url)
    _open_skills_list(page)

    banner = page.locator(
        "#ctx-skills-list .ctx-missing-canonical-remediation[data-tier='project_shared']"
    )
    banner.wait_for(timeout=5_000)
    text = banner.text_content() or ""
    assert "Click Import" in text, f"project_shared remediation should mention web Import: {text!r}"
    assert (
        "mm context init --include=agents,commands,skills "
        "--scope project_shared --confirm-project-shared"
    ) in text
    assert "mm context sync --include=agents,commands,skills --scope project_shared" in text


def test_q_pr4_langchange_clears_import_status_box(page, mm_web_url: str) -> None:
    """The Import status box (``ctx-skills-status``) is rendered via
    inline ``t()`` in ``renderImportResult`` and would stale on toggle.
    The listener clears it (via the ``loadCtxList`` re-issue, which wipes
    ``statusEl.innerHTML`` near the top of the function) rather than
    caching the receipt — caching would resurrect a stale message in
    misleading form after navigation.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    page.goto(mm_web_url)
    _open_skills_list(page)

    # Inject a fake import receipt directly into the status box — exercising
    # the real Import button would require modal-handling for the confirm
    # dialog and CSRF dance; the receipt content is what stales, so writing
    # the rendered HTML directly is sufficient to pin the listener's clear
    # behavior.
    page.evaluate(
        """() => {
            const el = document.getElementById('ctx-skills-status');
            el.innerHTML = '<div class="ctx-import-result">'
                + '<div class="ctx-import-priority">EN-RECEIPT-MARKER</div>'
                + '</div>';
        }"""
    )
    pre = page.locator("#ctx-skills-status").inner_html()
    assert "EN-RECEIPT-MARKER" in pre, (
        f"precondition: marker must be in status box before toggle, got {pre!r}"
    )

    page.evaluate("async () => { await I18N.setLang('ko'); }")
    # Wait for the listener's loadCtxList to clear the status box.
    page.wait_for_function(
        "() => {"
        "  const el = document.getElementById('ctx-skills-status');"
        "  return el && el.innerHTML === '';"
        "}",
        timeout=3_000,
    )
    post = page.locator("#ctx-skills-status").inner_html()
    assert post == "", f"Import status box must be cleared on toggle, got {post!r}"
    assert "EN-RECEIPT-MARKER" not in post, (
        f"stale import receipt must not survive language toggle: {post!r}"
    )


def test_q_pr4_langchange_preserves_unsaved_edit_buffer(page, mm_web_url: str) -> None:
    """Review finding (P1, data-loss): when the user has the canonical
    detail open in Edit mode with unsaved textarea changes, a language
    toggle would refetch and silently discard their work. The listener
    must capture the dirty buffer + edit-mode flag *before*
    ``loadCtxList`` resets the panes, then re-apply after
    ``loadCtxDetail``'s re-mount completes.

    This is distinct from the 409-conflict ``_ctxStashDraft`` path —
    that one stashes only when the user enters the conflict dialog, so
    a normal Edit-mode toggle has no protection there.

    Pin: enter Edit mode, type a sentinel into the textarea, toggle
    language, assert the textarea retains the user's content (not the
    server's freshly-fetched canonical_content) and the edit pane
    remains visible.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    page.route(
        "**/api/context/skills/demo-skill",
        lambda r: r.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps(
                {
                    "name": "demo-skill",
                    "canonical_path": "/srv/cwd/.memtomem/skills/demo-skill.md",
                    "content": "SERVER-CANONICAL-CONTENT\n",
                    "mtime_ns": "1700000000000000000",
                    "files": [],
                }
            ),
        ),
    )
    page.goto(mm_web_url)
    _open_skills_list(page)
    page.wait_for_function(
        "() => document.querySelectorAll("
        '  \'#ctx-skills-list details[data-scope-id="cwd-scope"] '
        ".ctx-card').length > 0",
        timeout=5_000,
    )
    page.locator("#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-card").first.click()
    page.wait_for_selector("#ctx-skills-detail .ctx-detail-edit-btn", timeout=3_000)
    # Enter Edit mode — flips #ctx-pane-edit visible and hides the tabs
    # (per loadCtxDetail's edit handler).
    page.locator("#ctx-skills-detail .ctx-detail-edit-btn").click()
    page.wait_for_function(
        "() => {"
        "  const ep = document.querySelector('#ctx-skills-detail #ctx-pane-edit');"
        "  return ep && !ep.hidden;"
        "}",
        timeout=2_000,
    )
    # Type a sentinel — distinct from the server canonical_content so a
    # silent refetch-and-overwrite would be deterministically observable.
    page.evaluate(
        """() => {
            const ta = document.querySelector('#ctx-skills-detail #ctx-edit-content');
            ta.value = 'USER-IN-PROGRESS-DRAFT-SENTINEL';
        }"""
    )

    page.evaluate("async () => { await I18N.setLang('ko'); }")
    # The listener should: stash the buffer, call loadCtxList (wipes
    # detail), call loadCtxDetail (refetches canonical), then re-apply
    # the buffer + reopen the edit pane on the freshly-mounted DOM.
    page.wait_for_function(
        "() => {"
        "  const ta = document.querySelector('#ctx-skills-detail #ctx-edit-content');"
        "  const ep = document.querySelector('#ctx-skills-detail #ctx-pane-edit');"
        "  return ta && ep && !ep.hidden && ta.value === 'USER-IN-PROGRESS-DRAFT-SENTINEL';"
        "}",
        timeout=4_000,
    )
    post_value = page.evaluate(
        "() => document.querySelector('#ctx-skills-detail #ctx-edit-content').value"
    )
    assert post_value == "USER-IN-PROGRESS-DRAFT-SENTINEL", (
        f"unsaved edit buffer must survive langchange — got: {post_value!r}"
    )
    # Symmetric negative: the server canonical content must NOT have
    # silently replaced the user's work.
    assert "SERVER-CANONICAL-CONTENT" not in post_value, (
        f"server canonical content must not silently overwrite the user's "
        f"in-progress edits on language toggle (data-loss regression): "
        f"{post_value!r}"
    )


def test_q_pr4_langchange_preserves_mtime_for_dirty_buffer(page, mm_web_url: str) -> None:
    """Review finding (P1, mtime conflict bypass): when a file changes
    on disk while the user has unsaved Edit-mode changes and they
    toggle language, ``loadCtxDetail`` overwrites
    ``detailEl.dataset.mtimeNs`` with the fresh on-disk mtime. If the
    listener didn't restore the *pre-toggle* mtime, the next Save
    would PUT the stale draft with the new mtime and bypass the
    backend's 409 conflict gate (issue #763), silently clobbering the
    external edit.

    Pin: open Edit mode at mtime=A, then route the canonical GET to
    return mtime=B (simulating an external edit during the toggle
    window), toggle language, assert ``detailEl.dataset.mtimeNs``
    still equals A so the next Save's PUT body carries the original
    pre-edit mtime.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    # Two distinct mtime values — A is the mtime the user started
    # editing against; B simulates an external edit landing during
    # the toggle window. The listener must keep A on the textarea
    # buffer so a Save re-surfaces the 409.
    initial_resp = {
        "name": "demo-skill",
        "canonical_path": "/srv/cwd/.memtomem/skills/demo-skill.md",
        "content": "ORIGINAL\n",
        "mtime_ns": "1700000000000000000",  # A
        "files": [],
    }
    refreshed_resp = {
        "name": "demo-skill",
        "canonical_path": "/srv/cwd/.memtomem/skills/demo-skill.md",
        "content": "EXTERNALLY-EDITED\n",
        "mtime_ns": "1800000000000000000",  # B
        "files": [],
    }
    call_count = {"n": 0}

    def _detail_handler(route):
        idx = call_count["n"]
        call_count["n"] += 1
        body = json.dumps(initial_resp if idx == 0 else refreshed_resp)
        route.fulfill(status=200, content_type="application/json", body=body)

    page.route("**/api/context/skills/demo-skill**", _detail_handler)
    page.goto(mm_web_url)
    _open_skills_list(page)
    page.wait_for_function(
        "() => document.querySelectorAll("
        '  \'#ctx-skills-list details[data-scope-id="cwd-scope"] '
        ".ctx-card').length > 0",
        timeout=5_000,
    )
    page.locator("#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-card").first.click()
    page.wait_for_function(
        "() => document.querySelector('#ctx-skills-detail').dataset.mtimeNs "
        "=== '1700000000000000000'",
        timeout=3_000,
    )
    page.locator("#ctx-skills-detail .ctx-detail-edit-btn").click()
    page.wait_for_function(
        "() => {"
        "  const ep = document.querySelector('#ctx-skills-detail #ctx-pane-edit');"
        "  return ep && !ep.hidden;"
        "}",
        timeout=2_000,
    )
    page.evaluate(
        """() => {
            const ta = document.querySelector('#ctx-skills-detail #ctx-edit-content');
            ta.value = 'USER-IN-PROGRESS-DRAFT';
        }"""
    )

    # Toggle language. The detail GET handler returns mtime B on this
    # second call, simulating an external on-disk edit landing during
    # the toggle window.
    page.evaluate("async () => { await I18N.setLang('ko'); }")
    page.wait_for_function(
        "() => {"
        "  const ta = document.querySelector('#ctx-skills-detail #ctx-edit-content');"
        "  return ta && ta.value === 'USER-IN-PROGRESS-DRAFT';"
        "}",
        timeout=4_000,
    )
    # The critical assertion: dataset.mtimeNs must be A (pre-toggle),
    # not B (the freshly-read value). Without the listener's restore
    # step, loadCtxDetail's `detailEl.dataset.mtimeNs = data.mtime_ns`
    # write at line ~988 would have stamped B onto the element while
    # the textarea still carried A's draft — exactly the 409-bypass
    # the review flagged.
    final_mtime = page.evaluate(
        "() => document.querySelector('#ctx-skills-detail').dataset.mtimeNs"
    )
    assert final_mtime == "1700000000000000000", (
        f"pre-toggle mtime must be restored when the textarea carries an "
        f"unsaved buffer; got {final_mtime!r}. The freshly-read mtime "
        f"(1800...) on the dirty buffer would let Save bypass the 409 "
        f"conflict gate and silently overwrite the external edit."
    )
    assert final_mtime != "1800000000000000000", (
        f"refreshed mtime ({final_mtime!r}) must not stamp onto a dirty "
        f"buffer — that's the 409 bypass the review flagged"
    )


def test_q_pr4_rapid_toggle_preserves_edit_buffer(page, mm_web_url: str) -> None:
    """Review finding (P2): two back-to-back langchange events while
    a dirty Edit buffer is open must not drop the buffer. The first
    listener invocation captures into ``_ctxPendingEdit`` and kicks off
    detail fetch #1; before that fetch settles, the second invocation
    fires. By then ``loadCtxList`` has already wiped the detail DOM,
    so a closure-local capture would yield ``null`` and the second
    mount would have no buffer to apply — silently dropping the user's
    work. The module-level ``_ctxPendingEdit`` + ``_ctxDetailSeq``
    guard pattern means the latest mount's ``.then()`` consumes the
    stash; older `.then()`s skip via seq mismatch.

    Mutation-validated: removing the module-level stash and
    ``myDetailSeq !== _ctxDetailSeq[type]`` bail makes this spec fail
    with the textarea reverting to the server's freshly-fetched
    canonical content.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    page.route(
        "**/api/context/skills/demo-skill**",
        lambda r: r.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps(
                {
                    "name": "demo-skill",
                    "canonical_path": "/srv/cwd/.memtomem/skills/demo-skill.md",
                    "content": "SERVER-CANONICAL\n",
                    "mtime_ns": "1700000000000000000",
                    "files": [],
                }
            ),
        ),
    )
    page.goto(mm_web_url)
    _open_skills_list(page)
    page.wait_for_function(
        "() => document.querySelectorAll("
        '  \'#ctx-skills-list details[data-scope-id="cwd-scope"] '
        ".ctx-card').length > 0",
        timeout=5_000,
    )
    page.locator("#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-card").first.click()
    page.wait_for_selector("#ctx-skills-detail .ctx-detail-edit-btn", timeout=3_000)
    page.locator("#ctx-skills-detail .ctx-detail-edit-btn").click()
    page.wait_for_function(
        "() => {"
        "  const ep = document.querySelector('#ctx-skills-detail #ctx-pane-edit');"
        "  return ep && !ep.hidden;"
        "}",
        timeout=2_000,
    )
    page.evaluate(
        """() => {
            const ta = document.querySelector('#ctx-skills-detail #ctx-edit-content');
            ta.value = 'RAPID-BUFFER-SENTINEL';
        }"""
    )

    # Fire TWO langchange events in the same JS task so both listener
    # invocations enter before either's detail fetch can resolve. The
    # second invocation will see a wiped DOM (loadCtxList from the
    # first invocation already cleared detailEl synchronously). With a
    # closure-local capture, the second invocation would have no
    # buffer to apply and the buffer would be lost when the second
    # detail fetch's response paints. With the module-level stash +
    # seq-guard pattern, the buffer survives.
    page.evaluate(
        """() => {
            window.dispatchEvent(new Event('langchange'));
            window.dispatchEvent(new Event('langchange'));
        }"""
    )

    # Wait for the latest mount to complete and (re-)apply the buffer.
    page.wait_for_function(
        "() => {"
        "  const ta = document.querySelector('#ctx-skills-detail #ctx-edit-content');"
        "  const ep = document.querySelector('#ctx-skills-detail #ctx-pane-edit');"
        "  return ta && ep && !ep.hidden && ta.value === 'RAPID-BUFFER-SENTINEL';"
        "}",
        timeout=4_000,
    )
    final_value = page.evaluate(
        "() => document.querySelector('#ctx-skills-detail #ctx-edit-content').value"
    )
    assert final_value == "RAPID-BUFFER-SENTINEL", (
        f"buffer must survive rapid back-to-back langchange events; got {final_value!r}"
    )
    assert "SERVER-CANONICAL" not in final_value, (
        f"server canonical content must not silently overwrite the user's "
        f"in-progress edits across rapid toggles (review P2 race): {final_value!r}"
    )


def test_q_pr4_langchange_rerenders_diff_pane(page, mm_web_url: str) -> None:
    """The Diff pane's localized content (per-runtime status badges) must
    follow a locale flip. Without active-tab capture, a ``loadCtxDetail``
    re-mount lands on the Canonical pane (``Click Diff tab to load...``)
    and the diff pane doesn't re-render until the user re-clicks Diff. The
    listener must capture ``.ctx-detail-tab[data-pane="diff"].active`` and
    pass ``autoOpenDiff: true`` so ``_ctxLoadDiff`` runs on re-mount —
    review finding P1. (Marker: the ``Out of sync`` / ``불일치`` runtime
    badge — the payload's only localized fragment on the real /diff wire
    shape; the diff pane renders the ``status_*`` long labels.)
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    # Canonical detail GET (any name match).
    page.route(
        "**/api/context/skills/demo-skill",
        lambda r: r.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps(
                {
                    "name": "demo-skill",
                    "canonical_path": "/srv/cwd/.memtomem/skills/demo-skill.md",
                    "content": "name: demo\n",
                    "mtime_ns": "1700000000000000000",
                    "files": [],
                }
            ),
        ),
    )
    page.route(
        "**/api/context/skills/demo-skill/diff**",
        lambda r: r.fulfill(
            status=200, content_type="application/json", body=json.dumps(_DIFF_OUT_OF_SYNC)
        ),
    )
    page.goto(mm_web_url)
    _open_skills_list(page)
    page.wait_for_function(
        "() => document.querySelectorAll("
        '  \'#ctx-skills-list details[data-scope-id="cwd-scope"] '
        ".ctx-card').length > 0",
        timeout=5_000,
    )
    page.locator("#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-card").first.click()
    page.wait_for_selector("#ctx-skills-detail .ctx-detail-tab", timeout=3_000)
    # Click the Diff tab to make it the active pane (so the listener
    # captures ``wasDiffActive = true``).
    page.locator("#ctx-skills-detail .ctx-detail-tab[data-pane='diff']").click()
    # The diff PANE's badge, not the list card's — scope to the pane id.
    page.wait_for_function(
        "() => document.querySelectorAll(  '#ctx-pane-skills-diff .ctx-runtime-badge').length > 0",
        timeout=3_000,
    )
    pre_badge = (
        page.locator("#ctx-pane-skills-diff .ctx-runtime-badge").first.text_content() or ""
    ).strip()
    assert "Out of sync" in pre_badge, f"EN diff badge should read 'Out of sync': {pre_badge!r}"

    page.evaluate("async () => { await I18N.setLang('ko'); }")
    # The listener should:
    # 1. Capture wasDiffActive=true.
    # 2. Call loadCtxList('skills') — wipes detail.
    # 3. Call loadCtxDetail(..., { autoOpenDiff: true }) — re-mounts and
    #    fires _ctxLoadDiff via diffTab.click().
    # End state: the diff pane re-renders with the KO badge text.
    page.wait_for_function(
        "() => {"
        "  const els = Array.from(document.querySelectorAll("
        "    '#ctx-pane-skills-diff .ctx-runtime-badge'));"
        "  return els.length > 0 && els.every(el => el.textContent.includes('불일치'));"
        "}",
        timeout=4_000,
    )
    post_badge = (
        page.locator("#ctx-pane-skills-diff .ctx-runtime-badge").first.text_content() or ""
    ).strip()
    assert "불일치" in post_badge, f"KO diff badge should read '불일치': {post_badge!r}"
    assert "Out of sync" not in post_badge, (
        f"EN literal must not survive KO toggle in the diff badge: {post_badge!r}"
    )


def test_q_pr4_langchange_rerenders_runtime_only_detail(page, mm_web_url: str) -> None:
    """A runtime-only detail pane is mounted by ``_ctxLoadRuntimeOnlyDetail``
    (not ``loadCtxDetail`` — that 404s for items with no canonical). The
    listener must read ``_ctxCurrentDetail.runtimeOnly`` and route the
    re-mount through the runtime-only loader; otherwise the detail
    silently degrades to ``emptyState`` (review finding P2).

    Pin: EN ``Runtime preview — not yet in .memtomem/.`` → KO
    ``런타임 미리보기 — 아직 .memtomem/ 에 없습니다.``
    """
    install_default_stubs(page)
    _stub_projects_wide(
        page,
        {
            "scopes": [
                {
                    "scope_id": "cwd-scope",
                    "label": "cwd",
                    "root": "/srv/cwd",
                    "tier": "user",
                    "sources": ["server-cwd"],
                    "experimental": False,
                    "missing": False,
                    "counts": {"skills": 1, "commands": 0, "agents": 0},
                }
            ]
        },
    )
    _stub_skills_wide(page, _CWD_SKILLS_RUNTIME_ONLY)
    # Canonical GET 404s for runtime-only — match what the real backend
    # does so a future regression that drops the runtimeOnly flag and
    # falls into loadCtxDetail surfaces as emptyState (KO 미리보기 absent).
    page.route(
        "**/api/context/skills/drift-skill**",
        lambda r: r.fulfill(
            status=404,
            content_type="application/json",
            body=json.dumps({"detail": "not found"}),
        ),
    )
    page.route(
        "**/api/context/skills/drift-skill/diff**",
        lambda r: r.fulfill(
            status=200, content_type="application/json", body=json.dumps(_DIFF_RUNTIME_ONLY)
        ),
    )
    page.goto(mm_web_url)
    _open_skills_list(page)
    page.wait_for_function(
        "() => document.querySelectorAll("
        '  \'#ctx-skills-list details[data-scope-id="cwd-scope"] '
        ".ctx-card').length > 0",
        timeout=5_000,
    )
    page.locator("#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-card").first.click()
    page.wait_for_function(
        "() => {"
        "  const el = document.querySelector('#ctx-skills-detail');"
        "  return el && el.textContent.includes('Runtime preview');"
        "}",
        timeout=5_000,
    )

    page.evaluate("async () => { await I18N.setLang('ko'); }")
    page.wait_for_function(
        "() => {"
        "  const el = document.querySelector('#ctx-skills-detail');"
        "  return el && el.textContent.includes('런타임 미리보기');"
        "}",
        timeout=4_000,
    )
    detail_text = (page.locator("#ctx-skills-detail").text_content() or "").strip()
    assert "런타임 미리보기" in detail_text, (
        f"KO runtime-only hint must be present after toggle: {detail_text!r}"
    )
    # Negative: the EN literal must not linger AND the emptyState fallback
    # (which would render if a regression dropped the runtimeOnly flag and
    # the listener fell into loadCtxDetail's 404 path) must not appear.
    assert "Runtime preview" not in detail_text, (
        f"EN literal must not survive KO toggle: {detail_text!r}"
    )


def test_q_pr4_langchange_off_section_does_not_call_loadCtxList(page, mm_web_url: str) -> None:
    """Off-section gate: a language toggle from a non-list page (e.g.,
    Search tab, or Settings → Hooks) must not fire
    ``/api/context/projects``. The dashboard's per-type section elements
    are always present in the DOM, so the listener must gate on
    ``classList.contains('active')`` rather than element existence.
    Mirror of ``test_langchange_off_overview_does_not_refetch_overview``
    in the overview suite.
    """
    install_default_stubs(page)

    projects_calls: list[str] = []

    def _projects_handler(route):
        projects_calls.append(route.request.url)
        route.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps(_CWD_PROJECTS_WITH_NON_CWD_MISSING),
        )

    # Trailing ``**`` so the now-``?include=counts`` query string still matches
    # (ADR-0021 PR2 made counts opt-in; the loader appends the param). Without
    # it a stray fetch would slip past this handler and the no-call assertion
    # below would pass vacuously.
    page.route("**/api/context/projects**", _projects_handler)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    page.goto(mm_web_url)
    page.locator("#tabbtn-search").click()
    page.wait_for_selector("#tabbtn-search.active", timeout=2_000)
    assert projects_calls == [], (
        f"loadCtxList must not fire on Search tab boot; got {projects_calls!r}"
    )

    page.evaluate("async () => { await I18N.setLang('ko'); }")
    page.evaluate("async () => { await I18N.setLang('en'); }")
    page.wait_for_timeout(300)
    assert projects_calls == [], (
        f"language toggle from off-section must not fetch projects; "
        f"saw {projects_calls!r} (Q-PR4 off-section gate)"
    )


# NOTE on race-window coverage:
#
# Earlier drafts of this file carried two browser specs that exercised
# the ``_ctxListSeq`` guard via overlapping fetches (success-path "stale
# fetch wins" and catch-path "late failure paints emptyState"). They
# were flaky in this harness — Playwright python's sync route
# dispatcher serializes handlers, so overlapping ``threading.Event``
# gates either deadlocked or scheduled non-deterministically.
#
# Shape parity with PR #824's ``_ctxOverviewSeq`` is enforced by the
# static test ``test_q_pr4_loadCtxList_has_sequence_guard_all_sites`` in
# ``test_i18n.py``: it asserts ≥4 ``_ctxListSeq[type]`` guards
# (loadCtxList success + catch + _loadScopeGroupItems success + catch),
# which is the same family of mechanism PR #824 already proved out at
# the integration level for the overview path. Mutating any of the four
# guards out of the source makes the static test fail; the runtime
# behavior is identical to the (already-tested) overview equivalent.


# -- Deep-link carrier (ADR-0009 §3, issue #834) -----------------------------
#
# Three skills payload: one out-of-sync, one in-sync, one runtime-only. Used
# by both the artifact-mode (negative pin) and filter-mode specs so the same
# stub serves both — the URL params drive divergent leaf renders without a
# different backend response.
_CWD_SKILLS_THREE = {
    "skills": [
        {
            "name": "alpha-skill",
            "canonical_path": "/srv/cwd/.memtomem/skills/alpha-skill.md",
            "runtimes": [{"runtime": "claude_skills", "status": "out of sync"}],
        },
        {
            "name": "beta-skill",
            "canonical_path": "/srv/cwd/.memtomem/skills/beta-skill.md",
            "runtimes": [{"runtime": "claude_skills", "status": "in sync"}],
        },
        {
            "name": "gamma-skill",
            "canonical_path": "",
            "runtimes": [
                {
                    "runtime": "claude_skills",
                    "status": "missing canonical",
                    "runtime_path": "/srv/cwd/.claude/skills/gamma-skill.md",
                }
            ],
        },
    ],
    "scanned_dirs": ["/srv/cwd/.claude/skills/"],
}


def _open_skills_with_query(page, mm_web_url: str, query: str) -> None:
    """Navigate to the SPA with a ``?section=ctx-skills&...`` query and
    wait for the cwd-scope cards (or the deep-link banner if no card
    matches) to settle.

    The deep-link applier runs *after* ``_loadScopeGroupItems`` populates
    the cwd container, so we wait on the banner element rather than the
    cards — both modes paint the banner, and a missing-link case has no
    cards at all to wait on.
    """
    page.goto(f"{mm_web_url}/{query}")
    page.evaluate("() => activateTab('settings')")
    page.evaluate("() => switchSettingsSection('ctx-skills')")
    page.wait_for_function(
        "() => document.querySelector(  '#ctx-skills-list .ctx-deep-link-banner') !== null",
        timeout=5_000,
    )


def test_deep_link_artifact_mode_renders_only_named_card(page, mm_web_url: str) -> None:
    """ADR-0009 §3 negative pin: when the deep-link carrier requests a
    single artifact, the leaf does NOT render the full list.

    ``feedback_pin_invert_symmetric_assertion.md`` calls for both halves:
    positive on the named card existing, negative on the legacy "all
    cards visible" shape — a regression where the filter is silently
    ignored and the full list re-appears would fail the second assert.
    The cards aren't merely hidden either: the applier removes them
    from the DOM so the count assertion is unambiguous (a CSS-driven
    ``display:none`` could pass a "1 visible" check while still leaking
    the rest into the live DOM and the tab order).
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_THREE)
    _open_skills_with_query(page, mm_web_url, "?section=ctx-skills&artifact=beta-skill")

    cwd_cards = page.locator("#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-card")
    # Positive: the named card is present.
    assert cwd_cards.count() == 1, (
        f"artifact-mode deep-link must render only 1 card, got {cwd_cards.count()}"
    )
    name = (cwd_cards.first.locator(".ctx-card-name").text_content() or "").strip()
    assert "beta-skill" in name, f"only-rendered card must be the named artifact, got {name!r}"
    # Negative: the unrelated cards are not in the DOM.
    other_names = page.locator(
        "#ctx-skills-list details[data-scope-id='cwd-scope'] "
        ".ctx-card[data-name='alpha-skill'], "
        "#ctx-skills-list details[data-scope-id='cwd-scope'] "
        ".ctx-card[data-name='gamma-skill']"
    )
    assert other_names.count() == 0, (
        "deep-link must remove non-matching cards (negative pin), "
        f"saw {other_names.count()} unrelated card(s) still in DOM"
    )
    # Banner is present and offers a reset.
    banner = page.locator("#ctx-skills-list .ctx-deep-link-banner")
    assert banner.count() == 1
    reset = banner.locator(".ctx-deep-link-banner-reset")
    assert reset.count() == 1


def test_deep_link_filter_mode_hides_non_matching_cards(page, mm_web_url: str) -> None:
    """``?filter=out_of_sync`` hides cards whose ``data-statuses`` doesn't
    include the bucket; matching cards stay visible.

    Filter mode uses ``hidden`` (not removal) so the banner reset can flip
    the cards back without re-fetching. Asserting on
    ``.is_visible()`` per Playwright covers the ``hidden`` attribute.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_THREE)
    _open_skills_with_query(page, mm_web_url, "?section=ctx-skills&filter=out_of_sync")

    alpha = page.locator(
        "#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-card[data-name='alpha-skill']"
    )
    beta = page.locator(
        "#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-card[data-name='beta-skill']"
    )
    gamma = page.locator(
        "#ctx-skills-list details[data-scope-id='cwd-scope'] .ctx-card[data-name='gamma-skill']"
    )

    # ``alpha-skill`` is the only out-of-sync entry.
    assert alpha.is_visible(), "alpha-skill (out_of_sync) must remain visible"
    assert not beta.is_visible(), "beta-skill (in_sync) must be hidden by the filter"
    assert not gamma.is_visible(), (
        "gamma-skill (missing_canonical) must be hidden by the out_of_sync filter"
    )


def test_deep_link_unknown_filter_is_treated_as_no_filter(page, mm_web_url: str) -> None:
    """Unknown ``?filter=`` values fall through to no-filter — keeps the
    URL parser permissive against accidental typos in shared links and
    forward-compatible with future filter buckets that haven't shipped
    yet. The leaf renders the full list and no banner appears.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_THREE)
    page.goto(f"{mm_web_url}/?section=ctx-skills&filter=bogus_value")
    page.evaluate("() => activateTab('settings')")
    page.evaluate("() => switchSettingsSection('ctx-skills')")
    # Wait on the cards rather than the banner (the bogus filter is
    # rejected so no banner is rendered).
    page.wait_for_function(
        "() => document.querySelectorAll("
        '  \'#ctx-skills-list details[data-scope-id="cwd-scope"] '
        ".ctx-card').length === 3",
        timeout=5_000,
    )
    banner = page.locator("#ctx-skills-list .ctx-deep-link-banner")
    assert banner.count() == 0, "unknown filter must not render a deep-link banner — saw one anyway"


def test_deep_link_banner_reset_restores_full_list(page, mm_web_url: str) -> None:
    """Clicking ``Show all`` clears the URL params and reloads the list
    unfiltered. Pins the round-trip (clear → reload → all 3 cards back).
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_THREE)
    _open_skills_with_query(page, mm_web_url, "?section=ctx-skills&artifact=beta-skill")

    page.locator("#ctx-skills-list .ctx-deep-link-banner .ctx-deep-link-banner-reset").click()

    # After reset, all three cards render again and the banner is gone.
    page.wait_for_function(
        "() => document.querySelectorAll("
        '  \'#ctx-skills-list details[data-scope-id="cwd-scope"] '
        ".ctx-card').length === 3",
        timeout=5_000,
    )
    assert page.locator("#ctx-skills-list .ctx-deep-link-banner").count() == 0, (
        "deep-link banner must be removed after reset"
    )
    # And the URL params are stripped.
    href = page.evaluate("() => window.location.href")
    assert "artifact=" not in href, f"reset must strip artifact param: {href}"
    assert "filter=" not in href, f"reset must strip filter param: {href}"


def test_deep_link_cold_load_routes_to_target_section(page, mm_web_url: str) -> None:
    """#1070: cold-loading ``?section=ctx-skills&filter=...#context-gateway``
    must route to the target leaf, not Overview.

    Previously, ``activateTab('context-gateway')`` consulted only
    ``opts.sectionOverride`` → ``STATE.lastSettingsSection`` → localStorage
    → ``ctx-overview``. The query-string was ignored on cold load, so the
    share-URL landed on Overview and ``_ctxApplyDeepLinkToContainer``
    never had a target list to filter. The previous deep-link specs in
    this file all cheated past this by explicitly calling
    ``activateTab('settings')`` + ``switchSettingsSection('ctx-skills')``
    after the goto — this spec deliberately does NOT, so it exercises the
    cold-load routing the issue actually reports.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_THREE)

    page.goto(f"{mm_web_url}/?section=ctx-skills&filter=missing_canonical#context-gateway")
    # Wait for cold-load routing to land on ctx-skills and the deep-link
    # banner to render. The banner is the same wait-handle the existing
    # specs use because both filter- and missing-link modes paint one.
    page.wait_for_function(
        "() => document.querySelector('#settings-ctx-skills.active') !== null"
        " && document.querySelector('#ctx-skills-list .ctx-deep-link-banner') !== null",
        timeout=5_000,
    )

    # Positive: the target section is the active one.
    assert page.locator("#settings-ctx-skills.active").count() == 1, (
        "cold-loaded ?section=ctx-skills must activate the Skills section"
    )
    # Negative: Overview is not the active section — pin the regression
    # shape (``activeSections: ['settings-ctx-overview']`` per the issue).
    assert page.locator("#settings-ctx-overview.active").count() == 0, (
        "cold-loaded deep-link must not land on Overview (regression pin for #1070)"
    )
    # And the filter banner is present, proving the deep-link payload
    # reached ``_ctxApplyDeepLinkToContainer`` after routing.
    assert page.locator("#ctx-skills-list .ctx-deep-link-banner").count() == 1


# Empty-list skills payload used by the #956 tier-aware empty-hint tests.
# ``scanned_dirs`` is present so the project-tier branch's ``{scan_dirs}``
# token interpolates with a real value (the user-tier branch ignores it).
_CWD_SKILLS_EMPTY = {
    "skills": [],
    "scanned_dirs": ["/srv/cwd/.claude/skills/"],
}


def _stub_projects_wide(page, payload):
    """Register a wider catch-all for ``/api/context/projects`` that also
    matches ``?target_scope=...``. ``loadCtxList`` builds the projects URL
    via ``_ctxWithTargetScope`` which appends the tier query string on
    non-shared tiers, so the narrow ``**/api/context/projects`` pattern
    misses the refetch after a tier swap and the catch-all empty body
    leaves the list panel in the "No project scopes" branch.
    """
    page.route(
        "**/api/context/projects**",
        lambda r: r.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps(payload),
        ),
    )


def _stub_skills_wide(page, payload):
    """Register a wider catch-all for ``/api/context/skills`` that also
    matches ``?target_scope=...``. Mirrors the inline pattern used by
    ``test_context_list_non_shared_tier_click_threads_target_scope``.
    """
    page.route(
        "**/api/context/skills**",
        lambda r: r.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps(payload),
        ),
    )


def test_q956_empty_hint_user_tier_paths_to_user_canonical(page, mm_web_url: str) -> None:
    """``_ctxRenderItemsHtml`` empty-state branches on ``_ctxTargetScope``
    (#956). On the user tier the canonical lives at ``~/.memtomem/<type>``
    and is shared across all projects, so the project-tier copy ("within
    this project" / import-from-scan-dirs) is wrong.

    Click the user tier button to trigger the refetch + re-render, then
    pin the user-tier empty-hint copy and the negative on the project
    framing.
    """
    install_default_stubs(page)
    _stub_projects_wide(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills_wide(page, _CWD_SKILLS_EMPTY)
    page.goto(mm_web_url)
    _open_skills_list(page)

    page.locator("#ctx-control-bar .ctx-tier-filter button[data-scope='user']").click()
    page.wait_for_function(
        "() => {"
        "  const b = document.querySelector("
        "    '#ctx-control-bar .ctx-tier-filter button[data-scope=\"user\"]');"
        "  return b && b.classList.contains('active');"
        "}",
        timeout=3_000,
    )
    page.wait_for_selector("#ctx-skills-list .empty-state", timeout=5_000)
    hint = page.locator("#ctx-skills-list .empty-state .empty-state-hint").text_content() or ""
    assert "~/.memtomem/skills" in hint, (
        f"user-tier empty hint should reference ~/.memtomem/skills: {hint!r}"
    )
    assert "within this project" not in hint, (
        f"user-tier empty hint must not say 'within this project': {hint!r}"
    )


def test_q956_empty_hint_project_tier_preserves_existing_copy(page, mm_web_url: str) -> None:
    """Regression pin for the project-tier (default ``project_shared``)
    empty hint: the ``.memtomem/<type>`` canonical token and "within this
    project" phrase must remain (#956 issue body: preserve project-tier
    wording).
    """
    install_default_stubs(page)
    _stub_projects_wide(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills_wide(page, _CWD_SKILLS_EMPTY)
    page.goto(mm_web_url)
    _open_skills_list(page)

    page.wait_for_selector("#ctx-skills-list .empty-state", timeout=5_000)
    hint = page.locator("#ctx-skills-list .empty-state .empty-state-hint").text_content() or ""
    assert ".memtomem/skills" in hint, (
        f"project-tier empty hint should reference .memtomem/skills: {hint!r}"
    )
    assert "within this project" in hint, (
        f"project-tier empty hint should retain 'within this project': {hint!r}"
    )


def test_q956_empty_hint_user_tier_ko_locale(page, mm_web_url: str) -> None:
    """Korean copy for the user-tier empty hint (#956): contains the
    ``사용자 티어`` phrase (the copy-polish pass dropped the mixed-script
    ``사용자 canonical`` jargon in favor of plain Korean) and drops the
    ``이 프로젝트의`` project framing.
    """
    install_default_stubs(page)
    _stub_projects_wide(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills_wide(page, _CWD_SKILLS_EMPTY)
    page.goto(mm_web_url)
    _open_skills_list(page)

    page.locator("#ctx-control-bar .ctx-tier-filter button[data-scope='user']").click()
    page.wait_for_function(
        "() => {"
        "  const b = document.querySelector("
        "    '#ctx-control-bar .ctx-tier-filter button[data-scope=\"user\"]');"
        "  return b && b.classList.contains('active');"
        "}",
        timeout=3_000,
    )
    page.evaluate("async () => { await I18N.setLang('ko'); }")
    page.wait_for_function(
        "() => {"
        "  const el = document.querySelector("
        "    '#ctx-skills-list .empty-state .empty-state-hint');"
        "  return el && el.textContent.includes('사용자 티어');"
        "}",
        timeout=3_000,
    )
    hint = page.locator("#ctx-skills-list .empty-state .empty-state-hint").text_content() or ""
    assert "사용자 티어" in hint, f"KO user-tier empty hint should contain '사용자 티어': {hint!r}"
    assert "이 프로젝트의" not in hint, (
        f"KO user-tier empty hint must not say '이 프로젝트의': {hint!r}"
    )


# --- 2026-06-10 diagnostics package (review W4 / U12) --------------------------


def test_mcp_user_tier_empty_hint_says_project_only(page, mm_web_url: str) -> None:
    """W4: MCP server definitions exist only at the project_shared tier — the
    backend returns a constant empty payload for other tiers and nothing ever
    reads ``~/.memtomem/mcp-servers``. Pre-change the user tier rendered the
    generic user-tier hint, directing users to stock that never-read path
    (and to press a Sync button the read-only tier disables).
    """
    install_default_stubs(page)
    _stub_projects_wide(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    page.route(
        "**/api/context/mcp-servers**",
        lambda r: r.fulfill(
            status=200,
            content_type="application/json",
            body=json.dumps({"mcp-servers": [], "scanned_dirs": []}),
        ),
    )
    page.goto(mm_web_url)
    page.evaluate("() => activateTab('settings')")
    page.evaluate("() => switchSettingsSection('ctx-mcp-servers')")
    page.wait_for_function(
        "() => document.querySelectorAll('#ctx-mcp-servers-list .ctx-scope-group').length > 0",
        timeout=5_000,
    )

    page.locator("#ctx-control-bar .ctx-tier-filter button[data-scope='user']").click()
    page.wait_for_function(
        "() => {"
        "  const el = document.querySelector("
        "    '#ctx-mcp-servers-list .empty-state .empty-state-hint');"
        "  return el && el.textContent.includes('Project (shared)');"
        "}",
        timeout=3_000,
    )
    hint = page.locator("#ctx-mcp-servers-list .empty-state .empty-state-hint").text_content() or ""
    assert "exist only at the Project (shared) tier" in hint, (
        f"user-tier MCP hint must say the artifact is project-only, got {hint!r}"
    )
    assert "~/.memtomem/mcp-servers" not in hint, (
        f"hint must not point at the never-read user-tier path, got {hint!r}"
    )


def test_import_receipt_priority_line_only_with_rows(page, mm_web_url: str) -> None:
    """U12: the cross-runtime import-priority rule line must render only when
    the receipt actually has imported/skipped rows — pre-change it
    unconditionally led even the "nothing imported" receipt.
    ``renderImportResult`` is a pure template helper, so pin it directly.
    """
    install_default_stubs(page)
    _stub_projects(page, _CWD_PROJECTS_WITH_NON_CWD_MISSING)
    _stub_skills(page, _CWD_SKILLS_ITEMS)
    page.goto(mm_web_url)
    _open_skills_list(page)

    empty_html = page.evaluate("() => renderImportResult({imported: [], skipped: []})")
    assert "ctx-import-priority" not in empty_html, (
        f"empty receipt must not lead with the priority rule, got {empty_html!r}"
    )
    rows_html = page.evaluate("() => renderImportResult({imported: [{name: 'x'}], skipped: []})")
    assert "ctx-import-priority" in rows_html, (
        f"receipt with rows must keep the priority rule, got {rows_html!r}"
    )
