"""Smoke-runs app.py with a minimal fake `streamlit`, simulating a chat session.

Real Streamlit isn't needed: this catches NameErrors/KeyErrors in every page and in the
AI chat flow (search, follow-ups, 'more', no-LLM fallback, fake LLM).
"""
import sys, os, types, re
from unittest.mock import MagicMock
ROOT = os.path.join(os.path.dirname(__file__), "..")
os.chdir(ROOT); sys.path.insert(0, ROOT)


class SS(dict):
    __getattr__ = dict.get
    def __setattr__(self, k, v): self[k] = v


def make_st(nav, prompts, session, password=""):
    st = MagicMock(name="streamlit")
    st.session_state = session
    def passthrough(*a, **k):
        if a and callable(a[0]) and len(a) == 1 and not k:
            f = a[0]; f.clear = lambda: None; return f
        def deco(f): f.clear = lambda: None; return f
        return deco
    st.cache_resource = types.SimpleNamespace(clear=lambda: None)
    st.cache_data = types.SimpleNamespace(clear=lambda: None)
    st.cache_resource = passthrough; st.cache_data = passthrough
    st.cache_resource.clear = st.cache_data.clear = lambda: None
    st.sidebar.radio.return_value = nav
    st.sidebar.button.return_value = False
    st.button.return_value = False
    st.text_input.return_value = password
    st.checkbox.return_value = True
    st.selectbox.side_effect = lambda label, options=(), *a, **k: (list(options)[0] if len(list(options)) else None)
    st.multiselect.return_value = []
    st.toggle.return_value = True
    st.number_input.side_effect = lambda label, min_value=0, max_value=None, value=None, *a, **k: (value if value is not None else min_value)
    st.slider.side_effect = lambda label, min_value=0, max_value=1, value=None, *a, **k: value if value is not None else min_value
    st.columns.side_effect = lambda spec, **k: [MagicMock() for _ in range(spec if isinstance(spec, int) else len(spec))]
    st.tabs.side_effect = lambda labels, **k: [MagicMock() for _ in labels]
    it = iter(prompts)
    st.chat_input.side_effect = lambda *a, **k: next(it, None)
    st.stop.side_effect = SystemExit
    return st


def run(nav, prompts=(), session=None, llm_client=None, password=""):
    session = session if session is not None else SS()
    st = make_st(nav, list(prompts), session, password)
    sys.modules["streamlit"] = st
    for m in ("app",): sys.modules.pop(m, None)
    src = open("app.py", encoding="utf-8").read()
    if llm_client is not None:   # inject a fake Gemini client
        src = src.replace("llm_client = None\nif gemini_key:", "llm_client = __FAKE__\nif False:")
    g = {"__name__": "__main__", "__FAKE__": llm_client}
    exec(compile(src, "app.py", "exec"), g)
    return st, session


class FakeLLM:
    working, last_error, last_cache_hit = "fake-model", "", False
    def __init__(self): self.understand_calls, self.stream_calls = [], []
    def understand(self, q, history=""):
        self.understand_calls.append(q)
        return {"author": "Laski"} if "zzzqqq" in q else {}
    def explain_stream(self, q, records, interp, total, history="", assumed_first=False, snapshot=""):
        assert snapshot == "2026-04-16" and len(records) <= 3 and (not records or "Availability=" in records[0])
        self.stream_calls.append(q)
        yield f"[LLM] {total} matches; "
        yield f"first: {records[0][:40] if records else 'none'}"


def chat(prompts, llm_client=None):
    """One script run per message, session state carried over -- exactly how Streamlit behaves."""
    session = SS()
    run("AI Assistant", [], session=session, llm_client=llm_client)      # first render (welcome message)
    for p in prompts:
        run("AI Assistant", [p], session=session, llm_client=llm_client)
    return session


def last_assistant(session):
    return [m for m in session["messages"] if m["role"] == "assistant"][-1]


if __name__ == "__main__":
    os.environ["ADMIN_PASSWORD"] = "secret"
    for nav in ["Dashboard", "Collection Map", "Library Catalog", "Reading List"]:
        run(nav); print("OK page:", nav)
    run("Admin Management", password="wrong"); print("OK page: Admin (wrong password)")
    run("Admin Management", password="secret"); print("OK page: Admin (authorised: health, copies, export)")

    # Cards must be one block: a blank line or indented line makes Streamlit print raw HTML
    import re as _re
    sess = SS(); run("AI Assistant", [], session=sess)
    for nav, prompts in [("Library Catalog", []), ("AI Assistant", ["Marx on economics"])]:
        st, _ = run(nav, prompts, session=sess if nav == "AI Assistant" else None)
        cards = [c.args[0] for c in st.markdown.call_args_list if c.args and "book-card" in str(c.args[0]) and "<h3>" in str(c.args[0])]
        assert cards, f"no cards rendered on {nav}"
        for c in cards:
            assert not _re.search(r"\n\s*\n", c) and not _re.search(r"\n {4}", c), "card HTML would render as a code block"
    print("OK cards are single HTML blocks")

    # AI page without an LLM (fallback path) -- multi-turn
    os.environ.pop("GEMINI_API_KEY", None)
    ss = chat(["accounting books published after 2010", "what is the isbn of the second one?", "more", "hello", "Title: nonexistent zzzz qqqq", "available books on algorithms", "is the first one available?"])
    msgs = [m for m in ss["messages"] if m["role"] == "assistant"]
    for m in msgs[1:]:
        print("  ->", m["content"][:90].replace("\n", " "), "| rows:", m.get("rows"), "|", (m.get("footer") or "")[:90].replace("\n", " "))
    assert msgs[1]["rows"] and len(msgs[1]["rows"]) == 5
    assert len(msgs[2]["rows"]) == 1 and msgs[2]["rows"][0] == msgs[1]["rows"][1], "follow-up should resolve to 2nd result"
    assert msgs[3]["rows"] and msgs[3]["rows"][0] not in msgs[1]["rows"], "'more' should give the next page"
    assert msgs[4]["rows"] == [] and msgs[5]["rows"] == []
    assert msgs[6]["rows"] and msgs[7]["rows"] == [msgs[6]["rows"][0]], "'first one' should resolve to the top available result"
    print("OK AI page (no LLM)")

    # AI page with a fake LLM: speed behaviour
    fake = FakeLLM()
    ss = chat(["books by harold laski", "is it available?", "ISBN: 0465026265", "zzzqqq xxxyyy", "more"], llm_client=fake)
    msgs = [m for m in ss["messages"] if m["role"] == "assistant"][1:]
    for m in msgs: print("  ->", m["content"][:70], "|", [f for f in (m.get("footer") or "").split("\n") if "⏱" in f])
    assert msgs[0]["content"].startswith("[LLM] 1 matches") and len(msgs[0]["rows"]) == 1
    assert "books by harold laski" not in fake.understand_calls, "built-in search found books: Gemini interpretation must be skipped"
    assert msgs[1]["rows"] == msgs[0]["rows"] and msgs[1]["content"].startswith("[LLM]")      # follow-up uses the AI
    assert "ISBN: 0465026265" not in fake.stream_calls, "exact lookups must not wait for Gemini"
    assert fake.understand_calls == ["zzzqqq xxxyyy"], "AI rephrase only when the built-in search finds nothing"
    assert msgs[3]["rows"] and "AI rephrased" in msgs[3]["footer"]
    assert all("⏱" in (m.get("footer") or "") for m in msgs[:4])
    print("OK AI page (fake LLM: skips unnecessary calls, streams, times itself)")

    # long chats: only the last 3 answers keep full cards
    import re as _re
    sess = chat(["accounting", "algorithms", "marketing", "economics", "statistics", "poverty"], llm_client=FakeLLM())
    st, _ = run("AI Assistant", [], session=sess, llm_client=FakeLLM())
    n_cards = sum(1 for c in st.markdown.call_args_list if c.args and "book-card" in str(c.args[0]) and "<h3>" in str(c.args[0]))
    n_compact = sum(1 for c in st.caption.call_args_list if c.args and str(c.args[0]).startswith("📚"))
    assert n_cards <= 3 * 5 and n_compact >= 3, (n_cards, n_compact)
    print(f"OK long chat renders {n_cards} cards + {n_compact} compact lines for older answers")
