"""Static-analysis tests for the LLM Wiki browser feature (issue #2941).

Verifies that:
1. /api/wiki/browse and /api/wiki/page route patterns exist in routes.py.
2. _renderLlmWikiStatus in panels.js references a browse action.
3. Path-traversal rejection (the ".." check) is present in the wiki page handler.
4. The four i18n keys are present in every locale block.
"""
from __future__ import annotations

import json
import os
import re
from pathlib import Path
from urllib.parse import urlparse

REPO = Path(__file__).resolve().parents[1]


class _FakeHandler:
    def __init__(self):
        self.status = None
        self.sent_headers: list[tuple[str, str]] = []
        self.body = bytearray()
        self.wfile = self

    def send_response(self, code):
        self.status = code

    def send_header(self, key, value):
        self.sent_headers.append((key, value))

    def end_headers(self):
        pass

    def write(self, data):
        self.body.extend(data if isinstance(data, (bytes, bytearray)) else data.encode("utf-8"))

    def get_json(self):
        return json.loads(self.body.decode("utf-8"))


def test_wiki_browse_route_exists_in_routes():
    src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
    assert '"/api/wiki/browse"' in src, "GET /api/wiki/browse route not found in routes.py"


def test_wiki_page_route_exists_in_routes():
    src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
    assert '"/api/wiki/page"' in src, "GET /api/wiki/page route not found in routes.py"


def test_wiki_page_path_traversal_rejection():
    src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
    # Traversal is rejected by a real `..` path SEGMENT check (not the bare
    # substring, which would also reject a legit filename like `v1..v2.md`).
    assert 'part == ".."' in src, "Segment-based path-traversal check not found in wiki page handler"
    assert "_skill_path_within" in src.split("/api/wiki/page")[1].split("/api/")[0], (
        "Symlink-safe _skill_path_within guard not found in /api/wiki/page handler"
    )


def test_render_llm_wiki_status_references_browse():
    src = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
    assert "_renderLlmWikiStatus" in src, "_renderLlmWikiStatus not found in panels.js"
    assert "_openWikiBrowser" in src, "_openWikiBrowser reference not found in panels.js"


def test_open_wiki_browser_function_exists():
    src = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
    assert "async function _openWikiBrowser" in src, "_openWikiBrowser function not defined in panels.js"
    assert "/api/wiki/browse" in src, "/api/wiki/browse fetch not found in panels.js"
    assert "/api/wiki/page" in src, "/api/wiki/page fetch not found in panels.js"


def test_wiki_browse_skips_pages_that_disappear_during_listing(monkeypatch, tmp_path):
    from api import routes

    wiki_root = tmp_path / "wiki"
    wiki_root.mkdir()
    ok = wiki_root / "ok.md"
    ok.write_text("# ok\n", encoding="utf-8")
    missing = wiki_root / "gone.md"

    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))
    monkeypatch.setattr(
        routes,
        "_llm_wiki_allowlisted_entries",
        lambda root: {
            "gone.md": (missing, (0, 0)),
            "ok.md": (ok, (ok.stat().st_dev, ok.stat().st_ino)),
        },
    )

    handler = _FakeHandler()
    routes.handle_get(handler, urlparse("http://example.com/api/wiki/browse"))

    assert handler.status == 200
    assert handler.get_json()["pages"] == [
        {
            "name": "ok.md",
            "path": "ok.md",
            "size": ok.stat().st_size,
            "mtime": int(ok.stat().st_mtime),
        }
    ]


def test_wiki_page_vanished_between_check_and_read_returns_404_not_500(monkeypatch, tmp_path):
    """TOCTOU: a page that disappears between is_file() and read_text() must
    return a clean 404, not let OSError bubble to the generic 500 handler."""
    from api import routes

    wiki_root = tmp_path / "wiki"
    wiki_root.mkdir()
    missing = (wiki_root / "gone.md").resolve()

    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))
    monkeypatch.setattr(routes, "_llm_wiki_allowlisted_entries", lambda root: {"gone.md": (missing, (0, 0))})

    handler = _FakeHandler()
    routes.handle_get(handler, urlparse("http://example.com/api/wiki/page?path=gone.md"))

    assert handler.status == 404, f"expected clean 404 on vanished page, got {handler.status}"


def test_wiki_page_read_is_restricted_to_listed_pages(monkeypatch, tmp_path):
    """The read endpoint must only serve files the browse/list path surfaces.
    A secret file inside the wiki root (e.g. .env) that is NOT a listed page
    must NOT be readable, even though it passes path-containment."""
    from api import routes

    wiki_root = tmp_path / "wiki"
    (wiki_root / "concepts").mkdir(parents=True)
    listed = wiki_root / "concepts" / "real.md"
    listed.write_text("# real page\n", encoding="utf-8")
    secret = wiki_root / ".env"
    secret.write_text("DONOTLEAK=secretmarker_abc\n", encoding="utf-8")

    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    # Attempt to read the secret (contained within the root, not a listed page).
    handler = _FakeHandler()
    routes.handle_get(handler, urlparse("http://example.com/api/wiki/page?path=.env"))
    assert handler.status == 404, f"secret .env must not be readable, got {handler.status}"
    assert b"secretmarker_abc" not in handler.body, "secret content leaked through /api/wiki/page"

    # The genuinely-listed page IS readable.
    handler2 = _FakeHandler()
    routes.handle_get(handler2, urlparse("http://example.com/api/wiki/page?path=concepts/real.md"))
    assert handler2.status == 200, f"listed page should be readable, got {handler2.status}"
    assert "real page" in handler2.get_json()["content"]


def test_wiki_symlink_page_cannot_escape_section_root(monkeypatch, tmp_path):
    """A listed-looking *.md symlink whose target resolves OUTSIDE its section
    dir (e.g. concepts/leak.md -> ../.env) must not be listed by browse nor
    readable by /api/wiki/page — the resolved real path is the security check."""
    import os as _os
    from api import routes

    wiki_root = tmp_path / "wiki"
    (wiki_root / "concepts").mkdir(parents=True)
    (wiki_root / "concepts" / "real.md").write_text("# real\n", encoding="utf-8")
    secret = wiki_root / ".env"
    secret.write_text("DONOTLEAK=leak_marker_xyz\n", encoding="utf-8")

    leak = wiki_root / "concepts" / "leak.md"
    try:
        leak.symlink_to(_os.path.join("..", ".env"))
    except (OSError, NotImplementedError):
        import pytest
        pytest.skip("symlinks not supported on this platform")

    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    # browse must NOT list the escaping symlink
    h_browse = _FakeHandler()
    routes.handle_get(h_browse, urlparse("http://example.com/api/wiki/browse"))
    listed_names = [p["path"] for p in h_browse.get_json()["pages"]]
    assert "concepts/leak.md" not in listed_names, "escaping symlink must not be listed"

    # page read of the symlink must 404 and must not leak the secret
    h_page = _FakeHandler()
    routes.handle_get(h_page, urlparse("http://example.com/api/wiki/page?path=concepts/leak.md"))
    assert h_page.status == 404, f"symlink-escape read must 404, got {h_page.status}"
    assert b"leak_marker" not in h_page.body and b"SECRET" not in h_page.body, "secret leaked via symlink page"


def test_wiki_symlink_to_hidden_same_section_target_blocked(monkeypatch, tmp_path):
    """A *.md symlink whose target is a HIDDEN file in the same section
    (concepts/link.md -> .hidden/secret.md) must not be listed or readable —
    the dot-segment rule applies to the RESOLVED target, not just the link name."""
    import os as _os
    from api import routes

    wiki_root = tmp_path / "wiki"
    hidden_dir = wiki_root / "concepts" / ".hidden"
    hidden_dir.mkdir(parents=True)
    (hidden_dir / "secret.md").write_text("DONOTLEAK=hidden_marker_q\n", encoding="utf-8")
    (wiki_root / "concepts" / "real.md").write_text("# real\n", encoding="utf-8")
    link = wiki_root / "concepts" / "link.md"
    try:
        link.symlink_to(_os.path.join(".hidden", "secret.md"))
    except (OSError, NotImplementedError):
        import pytest
        pytest.skip("symlinks not supported on this platform")

    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    h_browse = _FakeHandler()
    routes.handle_get(h_browse, urlparse("http://example.com/api/wiki/browse"))
    listed = [p["path"] for p in h_browse.get_json()["pages"]]
    assert "concepts/link.md" not in listed, "symlink to hidden target must not be listed"

    h_page = _FakeHandler()
    routes.handle_get(h_page, urlparse("http://example.com/api/wiki/page?path=concepts/link.md"))
    assert h_page.status == 404, f"symlink to hidden target must 404, got {h_page.status}"
    assert b"hidden_marker_q" not in h_page.body, "hidden secret leaked via symlink"


def test_wiki_page_cached_nested_entry_rechecks_resolved_containment(monkeypatch, tmp_path):
    import os as _os
    from api import routes

    wiki_root = tmp_path / "wiki"
    section = wiki_root / "concepts"
    nested = section / "sub"
    nested.mkdir(parents=True)
    page = nested / "real.md"
    page.write_text("# real\n", encoding="utf-8")
    secret = wiki_root / ".env"
    secret.write_text("DONOTLEAK=stale_cache_marker\n", encoding="utf-8")

    try:
        page.unlink()
        page.symlink_to(_os.path.join("..", "..", ".env"))
    except (OSError, NotImplementedError):
        import pytest
        pytest.skip("symlinks not supported on this platform")

    routes._llm_wiki_clear_page_files_cache()
    monkeypatch.setattr(routes, "_WIKI_ALLOWLIST_TTL", 60.0)
    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    # Prime the cached name list while the nested page is still valid, then
    # swap only the nested entry so the top-level section signature stays stale.
    page.unlink()
    page.write_text("# real\n", encoding="utf-8")
    assert routes._llm_wiki_page_files(wiki_root) == [page]
    sig_before = routes._llm_wiki_page_files_cache_signature(wiki_root.resolve())

    page.unlink()
    page.symlink_to(_os.path.join("..", "..", ".env"))
    sig_after = routes._llm_wiki_page_files_cache_signature(wiki_root.resolve())
    assert sig_after == sig_before

    handler = _FakeHandler()
    routes.handle_get(handler, urlparse("http://example.com/api/wiki/page?path=concepts/sub/real.md"))

    assert handler.status == 404, f"stale cached nested symlink swap must 404, got {handler.status}"
    assert b"stale_cache_marker" not in handler.body, "stale cached nested symlink leaked hidden content"


def test_wiki_page_cached_entry_rejects_hardlink_swap_to_outside(monkeypatch, tmp_path):
    """A hardlink swap to an outside file within the TTL must not leak (#4375).

    O_NOFOLLOW + inode-identity cannot distinguish a hardlink at a clean page
    name from the real page, so multi-link (st_nlink > 1) page files are
    rejected by both the allowlist walk and the read-path revalidation. Prime
    the cache with a real page, replace it with a hardlink to an outside secret
    within the TTL, and assert the read 404s without leaking the secret.
    """
    import os as _os
    from api import routes

    wiki_root = tmp_path / "wiki"
    section = wiki_root / "concepts"
    section.mkdir(parents=True)
    page = section / "page.md"
    page.write_text("# real\n", encoding="utf-8")
    outside = tmp_path / "outside_secret"
    outside.write_text("DONOTLEAK=hardlink_cache_marker\n", encoding="utf-8")

    routes._llm_wiki_clear_page_files_cache()
    monkeypatch.setattr(routes, "_WIKI_ALLOWLIST_TTL", 60.0)
    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    # Prime the cached name list while the page is a genuine single-link file.
    assert routes._llm_wiki_page_files(wiki_root) == [page]

    # Swap the listed page name to a hardlink pointing at the outside secret,
    # within the TTL window (cache still warm for the page NAME).
    page.unlink()
    try:
        _os.link(outside, page)
    except (OSError, NotImplementedError, AttributeError):
        import pytest
        pytest.skip("hardlinks not supported on this platform")

    handler = _FakeHandler()
    routes.handle_get(handler, urlparse("http://example.com/api/wiki/page?path=concepts/page.md"))

    assert handler.status == 404, f"cached page hardlinked to outside file must 404, got {handler.status}"
    assert b"hardlink_cache_marker" not in handler.body, "hardlink swap leaked outside content"


def test_wiki_browse_cached_entry_rechecks_resolved_containment(monkeypatch, tmp_path):
    import os as _os
    from api import routes

    wiki_root = tmp_path / "wiki"
    section = wiki_root / "concepts"
    nested = section / "sub"
    nested.mkdir(parents=True)
    page = nested / "real.md"
    page.write_text("# real\n", encoding="utf-8")
    env_file = wiki_root / ".env"
    env_file.write_text("DONOTLEAK=browse_cache_marker\n", encoding="utf-8")

    routes._llm_wiki_clear_page_files_cache()
    monkeypatch.setattr(routes, "_WIKI_ALLOWLIST_TTL", 60.0)
    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    assert routes._llm_wiki_page_files(wiki_root) == [page]

    page.unlink()
    try:
        page.symlink_to(_os.path.join("..", "..", ".env"))
    except (OSError, NotImplementedError):
        import pytest
        pytest.skip("symlinks not supported on this platform")

    handler = _FakeHandler()
    routes.handle_get(handler, urlparse("http://example.com/api/wiki/browse"))

    assert handler.status == 200
    assert handler.get_json()["pages"] == []


def test_wiki_browse_drops_cached_entry_replaced_by_directory(monkeypatch, tmp_path):
    from api import routes

    wiki_root = tmp_path / "wiki"
    section = wiki_root / "concepts"
    nested = section / "sub"
    nested.mkdir(parents=True)
    page = nested / "real.md"
    page.write_text("# real\n", encoding="utf-8")

    routes._llm_wiki_clear_page_files_cache()
    monkeypatch.setattr(routes, "_WIKI_ALLOWLIST_TTL", 60.0)
    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    assert routes._llm_wiki_page_files(wiki_root) == [page]

    page.unlink()
    page.mkdir()

    handler = _FakeHandler()
    routes.handle_get(handler, urlparse("http://example.com/api/wiki/browse"))

    assert handler.status == 200
    assert handler.get_json()["pages"] == []


def test_wiki_browse_rechecks_identity_after_allowlist_snapshot(monkeypatch, tmp_path):
    import os as _os
    from api import routes

    wiki_root = tmp_path / "wiki"
    page = wiki_root / "concepts" / "real.md"
    page.parent.mkdir(parents=True)
    page.write_text("# real\n", encoding="utf-8")
    (wiki_root / ".env").write_text("DONOTLEAK=post_snapshot_marker\n", encoding="utf-8")

    routes._llm_wiki_clear_page_files_cache()
    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    original = routes._llm_wiki_allowlisted_entries

    def swapped(root):
        entries = original(root)
        page.unlink()
        page.symlink_to(_os.path.join("..", ".env"))
        return entries

    monkeypatch.setattr(routes, "_llm_wiki_allowlisted_entries", swapped)

    handler = _FakeHandler()
    routes.handle_get(handler, urlparse("http://example.com/api/wiki/browse"))

    assert handler.status == 200
    assert handler.get_json()["pages"] == []


def test_wiki_page_cached_entry_cannot_jump_sections(monkeypatch, tmp_path):
    import os as _os
    from api import routes

    wiki_root = tmp_path / "wiki"
    page = wiki_root / "concepts" / "sub" / "real.md"
    page.parent.mkdir(parents=True)
    page.write_text("# real\n", encoding="utf-8")
    external_doc = wiki_root / "drafts" / "page.md"
    external_doc.parent.mkdir(parents=True)
    external_doc.write_text("cross_section_marker", encoding="utf-8")

    try:
        page.unlink()
        page.symlink_to(_os.path.join("..", "..", "drafts", "page.md"))
    except (OSError, NotImplementedError):
        import pytest
        pytest.skip("symlinks not supported on this platform")

    routes._llm_wiki_clear_page_files_cache()
    monkeypatch.setattr(routes, "_WIKI_ALLOWLIST_TTL", 60.0)
    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    page.unlink()
    page.write_text("# real\n", encoding="utf-8")
    assert routes._llm_wiki_page_files(wiki_root) == [page]

    page.unlink()
    page.symlink_to(_os.path.join("..", "..", "drafts", "page.md"))

    handler = _FakeHandler()
    routes.handle_get(handler, urlparse("http://example.com/api/wiki/page?path=concepts/sub/real.md"))

    assert handler.status == 404, f"stale cached cross-section swap must 404, got {handler.status}"
    assert b"cross_section_marker" not in handler.body, "stale cached page leaked another section's content"


def test_wiki_page_cached_entry_cannot_jump_to_other_allowlisted_page(monkeypatch, tmp_path):
    import os as _os
    from api import routes

    wiki_root = tmp_path / "wiki"
    page = wiki_root / "concepts" / "sub" / "real.md"
    page.parent.mkdir(parents=True)
    page.write_text("# real\n", encoding="utf-8")
    sibling = wiki_root / "entities" / "page.md"
    sibling.parent.mkdir(parents=True)
    sibling.write_text("allowlisted_elsewhere_marker", encoding="utf-8")

    try:
        page.unlink()
        page.symlink_to(_os.path.join("..", "..", "entities", "page.md"))
    except (OSError, NotImplementedError):
        import pytest
        pytest.skip("symlinks not supported on this platform")

    routes._llm_wiki_clear_page_files_cache()
    monkeypatch.setattr(routes, "_WIKI_ALLOWLIST_TTL", 60.0)
    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    page.unlink()
    page.write_text("# real\n", encoding="utf-8")
    assert set(routes._llm_wiki_page_files(wiki_root)) == {page, sibling}

    page.unlink()
    page.symlink_to(_os.path.join("..", "..", "entities", "page.md"))

    handler = _FakeHandler()
    routes.handle_get(handler, urlparse("http://example.com/api/wiki/page?path=concepts/sub/real.md"))

    assert handler.status == 404, f"stale cached swap to another allowlisted page must 404, got {handler.status}"
    assert b"allowlisted_elsewhere_marker" not in handler.body, "listed page leaked another allowlisted page's content"


def test_wiki_page_read_with_symlinked_root(monkeypatch, tmp_path):
    import os as _os
    from api import routes

    real_root = tmp_path / "real-wiki"
    (real_root / "concepts").mkdir(parents=True)
    page = real_root / "concepts" / "real.md"
    page.write_text("# real\n", encoding="utf-8")
    link_root = tmp_path / "link-wiki"

    try:
        _os.symlink(real_root, link_root, target_is_directory=True)
    except (OSError, NotImplementedError):
        import pytest
        pytest.skip("directory symlinks not supported on this platform")

    routes._llm_wiki_clear_page_files_cache()
    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (link_root, None, None))

    handler = _FakeHandler()
    routes.handle_get(handler, urlparse("http://example.com/api/wiki/page?path=concepts/real.md"))

    assert handler.status == 200, f"listed page should stay readable through symlinked wiki root, got {handler.status}"
    assert handler.get_json()["content"] == "# real\n"


def test_wiki_symlinked_section_cannot_expose_outside_tree(monkeypatch, tmp_path):
    """A symlinked SECTION dir (concepts -> /tmp/outside) must not expose files
    outside the real wiki root."""
    from api import routes

    wiki_root = tmp_path / "wiki"
    wiki_root.mkdir()
    outside = tmp_path / "outside"
    outside.mkdir()
    (outside / "leak.md").write_text("DONOTLEAK=outside_marker_z\n", encoding="utf-8")
    try:
        (wiki_root / "concepts").symlink_to(outside, target_is_directory=True)
    except (OSError, NotImplementedError):
        import pytest
        pytest.skip("symlinks not supported on this platform")

    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    h_browse = _FakeHandler()
    routes.handle_get(h_browse, urlparse("http://example.com/api/wiki/browse"))
    listed = [p["path"] for p in h_browse.get_json()["pages"]]
    assert listed == [], f"symlinked section must expose nothing, listed {listed}"


def test_wiki_legit_filename_with_dotdot_substring_opens(monkeypatch, tmp_path):
    """A legit listed page whose filename merely CONTAINS '..' (e.g. v1..v2.md)
    must still open — traversal rejection is per-segment, not substring."""
    from api import routes

    wiki_root = tmp_path / "wiki"
    (wiki_root / "concepts").mkdir(parents=True)
    (wiki_root / "concepts" / "v1..v2.md").write_text("# diff notes\n", encoding="utf-8")

    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    h = _FakeHandler()
    routes.handle_get(h, urlparse("http://example.com/api/wiki/page?path=concepts/v1..v2.md"))
    assert h.status == 200, f"legit filename with '..' substring should open, got {h.status}"
    assert "diff notes" in h.get_json()["content"]


def test_wiki_page_alias_spellings_are_rejected(monkeypatch, tmp_path):
    from api import routes

    wiki_root = tmp_path / "wiki"
    (wiki_root / "concepts").mkdir(parents=True)
    (wiki_root / "concepts" / "real.md").write_text("# real\n", encoding="utf-8")

    monkeypatch.setattr(routes, "_llm_wiki_resolve_path", lambda: (wiki_root, None, None))

    for alias in ("concepts/./real.md", "concepts//real.md", "concepts/real.md/", "concepts%5Creal.md"):
        handler = _FakeHandler()
        routes.handle_get(handler, urlparse(f"http://example.com/api/wiki/page?path={alias}"))
        assert handler.status == 400, f"alias spelling {alias!r} must be rejected, got {handler.status}"


def test_i18n_wiki_keys_in_all_locales():
    src = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
    required_keys = [
        "wiki_browse",
        "wiki_search_placeholder",
        "wiki_no_pages",
        "wiki_not_configured",
    ]
    # Locate all locale block boundaries by finding "_lang:" occurrences,
    # then verify each required key appears in every locale block.
    lang_positions = [m.start() for m in re.finditer(r"_lang:", src)]
    assert lang_positions, "Could not find any locale blocks in i18n data"

    locale_chunks = []
    for idx, start in enumerate(lang_positions):
        end = lang_positions[idx + 1] if idx + 1 < len(lang_positions) else len(src)
        locale_chunks.append(src[start:end])

    for i, chunk in enumerate(locale_chunks):
        for key in required_keys:
            assert key + ":" in chunk, (
                f"i18n key '{key}' missing from locale block {i + 1} "
                f"(position ~{lang_positions[i]})"
            )


def test_wiki_page_files_documents_hardlink_trust_boundary():
    from api import routes

    doc = routes._llm_wiki_page_files.__doc__ or ""
    assert "hardlink" in doc.lower()
    assert "trusted" in doc.lower() or "operator-controlled" in doc.lower()


def test_wiki_page_files_reuses_cache_with_unchanged_section_mtime(monkeypatch, tmp_path):
    from api import routes

    wiki_root = tmp_path / "wiki"
    (wiki_root / "concepts").mkdir(parents=True)
    page = wiki_root / "concepts" / "one.md"
    page.write_text("# one\n", encoding="utf-8")

    routes._llm_wiki_clear_page_files_cache()
    monkeypatch.setattr(routes, "_WIKI_ALLOWLIST_TTL", 60.0)

    calls = []

    def fake_uncached(root):
        calls.append(root)
        return [page]

    monkeypatch.setattr(routes, "_llm_wiki_page_files_uncached", fake_uncached)

    assert routes._llm_wiki_page_files(wiki_root) == [page]
    assert routes._llm_wiki_page_files(wiki_root) == [page]
    assert len(calls) == 1


def test_wiki_page_files_cache_invalidates_when_section_mtime_changes(monkeypatch, tmp_path):
    import time as _time
    from api import routes

    wiki_root = tmp_path / "wiki"
    section = wiki_root / "concepts"
    section.mkdir(parents=True)
    first = section / "one.md"
    second = section / "two.md"
    first.write_text("# one\n", encoding="utf-8")

    routes._llm_wiki_clear_page_files_cache()
    monkeypatch.setattr(routes, "_WIKI_ALLOWLIST_TTL", 60.0)

    calls = []

    def fake_uncached(root):
        calls.append(root)
        return [first] if len(calls) == 1 else [first, second]

    monkeypatch.setattr(routes, "_llm_wiki_page_files_uncached", fake_uncached)

    assert routes._llm_wiki_page_files(wiki_root) == [first]
    second.write_text("# two\n", encoding="utf-8")
    _time.sleep(0.02)  # Ensure mtime granularity is sufficient on Windows
    os.utime(str(section), None)
    assert routes._llm_wiki_page_files(wiki_root) == [first, second]
    assert len(calls) == 2


def test_wiki_page_files_cache_expires_after_ttl(monkeypatch, tmp_path):
    from api import routes

    wiki_root = tmp_path / "wiki"
    (wiki_root / "concepts").mkdir(parents=True)
    page = wiki_root / "concepts" / "one.md"
    page.write_text("# one\n", encoding="utf-8")

    routes._llm_wiki_clear_page_files_cache()
    monkeypatch.setattr(routes, "_WIKI_ALLOWLIST_TTL", 1.0)

    calls = []
    ticks = iter([100.0, 100.5, 101.5])
    monkeypatch.setattr(routes.time, "monotonic", lambda: next(ticks))

    def fake_uncached(root):
        calls.append(root)
        return [page]

    monkeypatch.setattr(routes, "_llm_wiki_page_files_uncached", fake_uncached)

    routes._llm_wiki_page_files(wiki_root)   # miss → rebuild (t=100.0)
    routes._llm_wiki_page_files(wiki_root)   # hit (t=100.5 < 101.0)
    routes._llm_wiki_page_files(wiki_root)   # miss → rebuild (t=101.5 > 101.0)
    assert len(calls) == 2
