"""Tests for the CLI interface using typer's test runner."""

import json
import logging
import os
import shutil
from pathlib import Path
from unittest import mock
from unittest.mock import AsyncMock, MagicMock

import pytest
from typer.testing import CliRunner

import lilbee.app.services as svc_mod
from lilbee.app.search import clean_result
from lilbee.app.version import get_version
from lilbee.cli import app
from lilbee.cli.tui import messages as msg
from lilbee.core.config import cfg
from lilbee.data.ingest import SyncResult
from lilbee.data.store import SearchChunk
from lilbee.modelhub.models import list_installed_models

runner = CliRunner()

_SYNC_NOOP = SyncResult()


def _mock_stream(*texts: str):
    from lilbee.retrieval.reasoning import StreamToken

    return iter([StreamToken(content=t, is_reasoning=False) for t in texts])


@pytest.fixture(autouse=True)
def _skip_model_validation():
    """CLI tests never need real model validation or chat model checks."""
    with mock.patch("lilbee.modelhub.models.ensure_chat_model", return_value=None):
        yield


@pytest.fixture(autouse=True)
def mock_svc():
    """Provide a mock Services container for all CLI tests."""
    from tests.conftest import make_mock_services

    searcher = MagicMock()
    searcher.search.return_value = []
    searcher.ask_stream.return_value = _mock_stream("")
    store = MagicMock()
    store.search.return_value = []
    store.bm25_probe.return_value = []
    store.get_sources.return_value = []
    store.add_chunks.return_value = 0
    embedder = MagicMock()
    embedder.embed.return_value = [0.1] * 768
    embedder.embed_batch.return_value = []
    services = make_mock_services(searcher=searcher, store=store, embedder=embedder)
    svc_mod.set_services(services)
    yield services
    svc_mod.set_services(None)


@pytest.fixture(autouse=True)
def isolated_env(tmp_path, monkeypatch):
    """Redirect config paths for all CLI tests."""
    monkeypatch.delenv("LILBEE_DATA", raising=False)
    monkeypatch.delenv("LILBEE_LOG_LEVEL", raising=False)
    snapshot = cfg.model_copy()
    root = logging.getLogger()
    old_level = root.level
    old_handlers = root.handlers[:]

    cfg.data_root = tmp_path
    cfg.documents_dir = tmp_path / "documents"
    cfg.documents_dir.mkdir(exist_ok=True)
    cfg.data_dir = tmp_path / "data"
    cfg.lancedb_dir = tmp_path / "data" / "lancedb"
    cfg.json_mode = False
    cfg.concept_graph = False

    yield tmp_path

    for name in type(cfg).model_fields:
        setattr(cfg, name, getattr(snapshot, name))
    root.setLevel(old_level)
    root.handlers[:] = old_handlers


class TestStatus:
    def test_empty_status(self):
        result = runner.invoke(app, ["status"])
        assert result.exit_code == 0
        assert "No documents indexed" in result.output

    def test_status_shows_paths(self):
        result = runner.invoke(app, ["status"])
        assert "Documents:" in result.output
        assert "Database:" in result.output

    def test_status_shows_models(self):
        result = runner.invoke(app, ["status"])
        assert "Chat model:" in result.output
        assert "Embeddings:" in result.output

    def test_status_shows_ocr_when_enabled(self):
        cfg.enable_ocr = True
        result = runner.invoke(app, ["status"])
        assert "Vision OCR:" in result.output
        assert "enabled" in result.output

    def test_status_hides_ocr_when_none(self):
        cfg.enable_ocr = None
        result = runner.invoke(app, ["status"])
        assert "Vision OCR:" not in result.output

    def test_status_with_indexed_docs(self, isolated_env, mock_svc):
        mock_svc.store.get_sources.return_value = [
            {
                "filename": "test.pdf",
                "file_hash": "abc123",
                "chunk_count": 10,
                "ingested_at": "2026-01-01T00:00:00",
            }
        ]
        result = runner.invoke(app, ["status"])
        assert "test.pdf" in result.output
        assert "10" in result.output


class TestSync:
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_sync_empty(self, mock_sync):
        result = runner.invoke(app, ["sync"])
        assert result.exit_code == 0
        assert "Added: 0" in result.output

    @mock.patch(
        "lilbee.data.ingest.sync",
        new_callable=AsyncMock,
        return_value=SyncResult(added=["test.txt"]),
    )
    def test_sync_with_file(self, mock_sync, isolated_env):
        (cfg.documents_dir / "test.txt").write_text("Hello world content.")
        result = runner.invoke(app, ["sync"])
        assert result.exit_code == 0
        assert "Added: 1" in result.output

    @mock.patch(
        "lilbee.data.ingest.sync",
        new_callable=AsyncMock,
        return_value=SyncResult(failed=["bad.txt"]),
    )
    def test_sync_shows_failed(self, mock_sync):
        result = runner.invoke(app, ["sync"])
        assert "Failed: 1" in result.output
        assert "bad.txt" in result.output

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_sync_retry_skipped_flag(self, mock_sync):
        """`lilbee sync --retry-skipped` forwards retry_skipped=True to the engine."""
        result = runner.invoke(app, ["sync", "--retry-skipped"])
        assert result.exit_code == 0
        assert mock_sync.call_args.kwargs.get("retry_skipped") is True

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_sync_without_flag_does_not_retry_skipped(self, mock_sync):
        runner.invoke(app, ["sync"])
        assert mock_sync.call_args.kwargs.get("retry_skipped") is False


class TestRebuild:
    def test_rebuild_empty(self):
        result = runner.invoke(app, ["rebuild"])
        assert result.exit_code == 0
        assert "Rebuilt:" in result.output


class TestAdd:
    def test_add_single_file(self, isolated_env, tmp_path):
        """Adding a single file copies it and ingests it."""
        src_file = tmp_path / "source" / "manual.txt"
        src_file.parent.mkdir()
        src_file.write_text("Engine oil capacity is 5 quarts.")

        result = runner.invoke(app, ["add", str(src_file)])
        assert result.exit_code == 0
        assert "Copied 1" in result.output
        assert (cfg.documents_dir / "manual.txt").exists()

    def test_add_directory(self, isolated_env, tmp_path):
        """Adding a directory recursively copies it."""
        src_dir = tmp_path / "source" / "docs"
        src_dir.mkdir(parents=True)
        (src_dir / "file1.txt").write_text("Content 1")
        (src_dir / "file2.txt").write_text("Content 2")

        result = runner.invoke(app, ["add", str(src_dir)])
        assert result.exit_code == 0
        assert (cfg.documents_dir / "docs" / "file1.txt").exists()
        assert (cfg.documents_dir / "docs" / "file2.txt").exists()

    def test_add_multiple_paths(self, isolated_env, tmp_path):
        """Adding multiple paths works."""
        f1 = tmp_path / "source" / "a.txt"
        f2 = tmp_path / "source" / "b.txt"
        f1.parent.mkdir()
        f1.write_text("File A")
        f2.write_text("File B")

        result = runner.invoke(app, ["add", str(f1), str(f2)])
        assert result.exit_code == 0
        assert "Copied 2" in result.output

    def test_add_nonexistent_fails(self, tmp_path):
        """Adding a nonexistent path fails."""
        result = runner.invoke(app, ["add", str(tmp_path / "nonexistent_file_xyz.txt")])
        assert result.exit_code != 0

    def test_add_overwrites_existing_dir(self, isolated_env, tmp_path):
        """Re-adding a directory with --force updates content."""
        src_dir = tmp_path / "source" / "docs"
        src_dir.mkdir(parents=True)
        (src_dir / "file1.txt").write_text("Version 1")

        runner.invoke(app, ["add", "--force", str(src_dir)])

        # Update content and re-add with --force
        (src_dir / "file1.txt").write_text("Version 2")
        result = runner.invoke(app, ["add", "--force", str(src_dir)])
        assert result.exit_code == 0
        assert (cfg.documents_dir / "docs" / "file1.txt").read_text() == "Version 2"

    def test_add_warns_on_existing(self, isolated_env, tmp_path):
        """Adding a file that already exists warns without --force."""
        src_file = tmp_path / "source" / "manual.txt"
        src_file.parent.mkdir()
        src_file.write_text("Original content")

        runner.invoke(app, ["add", "--force", str(src_file)])

        src_file.write_text("New content")
        result = runner.invoke(app, ["add", str(src_file)])
        assert result.exit_code == 0
        assert "Warning" in result.output
        assert "already exists" in result.output


class TestAddIgnoresDirs:
    def test_add_directory_skips_git_and_node_modules(self, isolated_env, tmp_path):
        """Adding a directory filters out .git/ and node_modules/."""
        src_dir = tmp_path / "source" / "project"
        src_dir.mkdir(parents=True)
        (src_dir / "readme.txt").write_text("Real content")
        (src_dir / ".git").mkdir()
        (src_dir / ".git" / "config").write_text("git stuff")
        (src_dir / "node_modules").mkdir()
        (src_dir / "node_modules" / "pkg.txt").write_text("npm junk")
        (src_dir / "__pycache__").mkdir()
        (src_dir / "__pycache__" / "mod.pyc").write_bytes(b"\x00")

        result = runner.invoke(app, ["add", str(src_dir)])
        assert result.exit_code == 0

        dest = cfg.documents_dir / "project"
        assert (dest / "readme.txt").exists()
        assert not (dest / ".git").exists()
        assert not (dest / "node_modules").exists()
        assert not (dest / "__pycache__").exists()


class TestAsk:
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_prints_response(self, mock_sync, mock_svc):
        mock_svc.searcher.ask_stream.return_value = _mock_stream("Hello", " world")
        result = runner.invoke(app, ["ask", "test question"])
        assert result.exit_code == 0

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_with_model_flag(self, mock_sync, mock_svc):
        mock_svc.searcher.ask_stream.return_value = _mock_stream("answer")
        result = runner.invoke(app, ["ask", "question", "--model", "ollama/llama3:8b"])
        assert result.exit_code == 0

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_scope_wiki_reaches_ask_stream(self, mock_sync, mock_svc):
        mock_svc.searcher.ask_stream.return_value = _mock_stream("a")
        result = runner.invoke(app, ["ask", "--scope", "wiki", "q"])
        assert result.exit_code == 0
        assert mock_svc.searcher.ask_stream.call_args.kwargs.get("chunk_type") == "wiki"

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_scope_raw_reaches_ask_stream(self, mock_sync, mock_svc):
        mock_svc.searcher.ask_stream.return_value = _mock_stream("a")
        result = runner.invoke(app, ["ask", "--scope", "raw", "q"])
        assert result.exit_code == 0
        assert mock_svc.searcher.ask_stream.call_args.kwargs.get("chunk_type") == "raw"

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_default_scope_is_mixed_pool(self, mock_sync, mock_svc):
        mock_svc.searcher.ask_stream.return_value = _mock_stream("a")
        result = runner.invoke(app, ["ask", "q"])
        assert result.exit_code == 0
        assert mock_svc.searcher.ask_stream.call_args.kwargs.get("chunk_type") is None

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_invalid_scope_exits_nonzero(self, mock_sync, mock_svc):
        result = runner.invoke(app, ["ask", "--scope", "bogus", "q"])
        assert result.exit_code != 0
        mock_svc.searcher.ask_stream.assert_not_called()

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_json_scope_reaches_ask_raw(self, mock_sync, mock_svc):
        from lilbee.retrieval.query import AskResult

        mock_svc.searcher.ask_raw.return_value = AskResult(answer="a", sources=[])
        result = runner.invoke(app, ["--json", "ask", "--scope", "wiki", "q"])
        assert result.exit_code == 0
        assert mock_svc.searcher.ask_raw.call_args.kwargs.get("chunk_type") == "wiki"


def _mismatch_error(*, dims_match=True):
    from lilbee.data.store import EmbeddingModelMismatchError

    return EmbeddingModelMismatchError(
        persisted_model="orgA/repoA/built.gguf",
        persisted_dim=768,
        current_model="orgB/repoB/configured.gguf",
        current_dim=768 if dims_match else 384,
    )


class TestEmbeddingMismatchCli:
    """Headless commands never switch embedder silently; they name the index's
    embedder and the one-command fix when the index is adoptable."""

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_mismatch_names_use_embedder_command(self, mock_sync, mock_svc):
        mock_svc.searcher.ask_stream.side_effect = _mismatch_error()
        result = runner.invoke(app, ["ask", "q"])
        assert result.exit_code == 1
        assert "use-embedder orgA/repoA/built.gguf" in result.output

    def test_search_mismatch_adoptable_hint(self, mock_svc):
        mock_svc.searcher.search.side_effect = _mismatch_error()
        result = runner.invoke(app, ["search", "q"])
        assert result.exit_code == 1
        assert "use-embedder orgA/repoA/built.gguf" in result.output

    def test_search_mismatch_dim_incompatible_points_to_rebuild(self, mock_svc):
        mock_svc.searcher.search.side_effect = _mismatch_error(dims_match=False)
        result = runner.invoke(app, ["search", "q"])
        assert result.exit_code == 1
        assert "rebuild" in result.output.lower()
        assert "use-embedder" not in result.output

    def test_search_mismatch_json_carries_persisted_model(self, mock_svc):
        mock_svc.searcher.search.side_effect = _mismatch_error()
        result = runner.invoke(app, ["--json", "search", "q"])
        assert result.exit_code == 1
        payload = json.loads(result.output)
        assert payload["persisted_model"] == "orgA/repoA/built.gguf"


class TestUseEmbedderCommand:
    @mock.patch("lilbee.app.models.adopt_embedder")
    def test_use_embedder_reports_active_model(self, mock_adopt, mock_svc):
        from lilbee.app.models import AdoptResult, AdoptStatus

        mock_adopt.return_value = AdoptResult(
            model="orgA/repoA/built.gguf", status=AdoptStatus.ADOPTED
        )
        result = runner.invoke(app, ["use-embedder", "orgA/repoA/built.gguf"])
        assert result.exit_code == 0
        assert "orgA/repoA/built.gguf" in result.output
        mock_adopt.assert_called_once_with("orgA/repoA/built.gguf")

    @mock.patch("lilbee.app.models.adopt_embedder")
    def test_use_embedder_json_output(self, mock_adopt, mock_svc):
        from lilbee.app.models import AdoptResult, AdoptStatus

        mock_adopt.return_value = AdoptResult(
            model="orgA/repoA/built.gguf", status=AdoptStatus.ALREADY_ACTIVE
        )
        result = runner.invoke(app, ["--json", "use-embedder", "orgA/repoA/built.gguf"])
        assert result.exit_code == 0
        payload = json.loads(result.output)
        assert payload["status"] == "already_active"

    @mock.patch("lilbee.app.models.adopt_embedder")
    def test_use_embedder_failure_exits_nonzero(self, mock_adopt, mock_svc):
        mock_adopt.side_effect = RuntimeError("no such model")
        result = runner.invoke(app, ["use-embedder", "bogus/ref.gguf"])
        assert result.exit_code == 1
        assert "no such model" in result.output

    @mock.patch("lilbee.app.models.adopt_embedder")
    def test_use_embedder_failure_json(self, mock_adopt, mock_svc):
        mock_adopt.side_effect = RuntimeError("no such model")
        result = runner.invoke(app, ["--json", "use-embedder", "bogus/ref.gguf"])
        assert result.exit_code == 1
        assert json.loads(result.output)["error"] == "no such model"


class TestIndexCommand:
    def test_index_builds_search_indexes(self, mock_svc):
        mock_svc.store.ensure_vector_index.return_value = True
        result = runner.invoke(app, ["index"])
        assert result.exit_code == 0
        assert "built" in result.output.lower()
        mock_svc.store.ensure_fts_index.assert_called_once()
        mock_svc.store.ensure_vector_index.assert_called_once_with(force=True)

    def test_index_reports_when_vector_index_skipped(self, mock_svc):
        mock_svc.store.ensure_vector_index.return_value = False
        result = runner.invoke(app, ["index"])
        assert result.exit_code == 0
        assert "more chunks" in result.output.lower()

    def test_index_json_output(self, mock_svc):
        mock_svc.store.ensure_vector_index.return_value = True
        result = runner.invoke(app, ["--json", "index"])
        assert result.exit_code == 0
        assert json.loads(result.output) == {"command": "index", "vector_index": True}


class TestDataDirFlag:
    def test_status_with_data_dir_after_subcommand(self, tmp_path):
        custom = tmp_path / "custom"
        custom.mkdir()
        (custom / "documents").mkdir()
        result = runner.invoke(app, ["status", "--data-dir", str(custom)])
        assert result.exit_code == 0

    def test_sync_with_data_dir_after_subcommand(self, tmp_path):
        custom = tmp_path / "custom"
        custom.mkdir()
        (custom / "documents").mkdir()
        result = runner.invoke(app, ["sync", "--data-dir", str(custom)])
        assert result.exit_code == 0

    def test_data_dir_before_subcommand_redirects_paths(self, tmp_path):
        """`lilbee --data-dir X status` must point cfg at X.

        Typer binds options placed before the subcommand to the group
        callback, which used to only apply them for the no-subcommand TUI
        path; the subcommand then saw ``data_dir=None`` and silently kept
        the global paths.
        """
        custom = tmp_path / "custom"
        (custom / "documents").mkdir(parents=True)
        result = runner.invoke(app, ["--data-dir", str(custom), "status"])
        assert result.exit_code == 0
        assert cfg.data_root == custom
        assert cfg.documents_dir == custom / "documents"
        assert cfg.data_dir == custom / "data"

    def test_model_before_subcommand_applies(self, tmp_path):
        """`lilbee -m ref status` applies the chat-model override at the callback."""
        custom = tmp_path / "custom"
        (custom / "documents").mkdir(parents=True)
        ref = "org/Some-GGUF/some-Q4_K_M.gguf"
        result = runner.invoke(app, ["-m", ref, "--data-dir", str(custom), "status"])
        assert result.exit_code == 0
        assert cfg.chat_model == ref


class TestAutoSync:
    @mock.patch(
        "lilbee.data.ingest.sync",
        new_callable=AsyncMock,
        return_value=SyncResult(added=["new.pdf"]),
    )
    def test_auto_sync_prints_summary(self, mock_sync, mock_svc):
        mock_svc.searcher.ask_stream.return_value = _mock_stream("answer")
        result = runner.invoke(app, ["ask", "test"])
        assert result.exit_code == 0
        assert "Synced:" in result.output

    def test_auto_sync_background(self) -> None:
        from rich.console import Console

        from lilbee.cli.helpers import auto_sync

        con = Console()
        with mock.patch("lilbee.cli.sync.run_sync_background") as mock_bg:
            auto_sync(con, background=True)
            mock_bg.assert_called_once_with(con)


class TestAddPathsBackground:
    def test_add_paths_background_mode(self, isolated_env, tmp_path) -> None:
        from rich.console import Console

        from lilbee.cli.helpers import add_paths

        src = tmp_path / "source" / "test.txt"
        src.parent.mkdir()
        src.write_text("content")
        con = Console()
        with (
            mock.patch("lilbee.cli.sync.run_sync_background") as mock_bg,
            mock.patch("lilbee.cli.helpers.copy_paths", return_value=[src]),
        ):
            add_paths([src], con, background=True)
            mock_bg.assert_called_once()

    def test_add_paths_chat_mode_prints(self, isolated_env, tmp_path, capsys) -> None:
        from rich.console import Console

        from lilbee.cli.helpers import add_paths

        src = tmp_path / "source" / "test.txt"
        src.parent.mkdir()
        src.write_text("content")
        con = Console()
        with (
            mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP),
            mock.patch("lilbee.cli.helpers.copy_paths", return_value=[src]),
        ):
            add_paths([src], con, chat_mode=True)
            captured = capsys.readouterr()
            assert "Copied 1 path(s)" in captured.out


class TestChat:
    def test_chat_non_tty_exits_with_error(self) -> None:
        result = runner.invoke(app, ["chat"])
        assert result.exit_code == 1
        assert "terminal" in result.output.lower()

    def test_chat_json_returns_json_error(self) -> None:
        result = runner.invoke(app, ["--json", "chat"])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "error" in data
        assert "terminal" in data["error"].lower() or "json" in data["error"].lower()


class TestApplyOverrides:
    def test_data_dir_override(self, tmp_path):
        from lilbee.cli import apply_overrides

        apply_overrides(data_dir=tmp_path)
        assert tmp_path / "documents" == cfg.documents_dir

    def test_model_override(self):
        from lilbee.cli import apply_overrides

        ref = "ollama/phi3:latest"
        apply_overrides(model=ref)
        assert cfg.chat_model == ref

    def test_none_values_are_noop(self):
        from lilbee.cli import apply_overrides

        original_model = cfg.chat_model
        apply_overrides(data_dir=None, model=None)
        assert original_model == cfg.chat_model

    def test_use_global_resets_to_platform_default(self):
        from lilbee.cli import apply_overrides
        from lilbee.core.system import default_data_dir

        apply_overrides(use_global=True)
        expected = default_data_dir()
        assert cfg.data_root == expected
        assert cfg.documents_dir == expected / "documents"
        assert cfg.data_dir == expected / "data"
        assert cfg.lancedb_dir == expected / "data" / "lancedb"

    def test_use_global_with_data_dir_raises(self, tmp_path):
        import typer

        from lilbee.cli import apply_overrides

        with pytest.raises(typer.BadParameter, match="Cannot use --global with --data-dir"):
            apply_overrides(data_dir=tmp_path, use_global=True)

    def test_lilbee_data_env_overrides_local_root(self, tmp_path, monkeypatch):
        """LILBEE_DATA env var takes precedence over .lilbee/ walk-up."""
        from lilbee.cli import apply_overrides

        env_dir = tmp_path / "env-data"
        env_dir.mkdir()
        monkeypatch.setenv("LILBEE_DATA", str(env_dir))
        apply_overrides()
        assert cfg.data_root == env_dir
        assert cfg.documents_dir == env_dir / "documents"

    def test_lilbee_data_env_ignored_when_data_dir_passed(self, tmp_path, monkeypatch):
        """Explicit --data-dir takes precedence over LILBEE_DATA."""
        from lilbee.cli import apply_overrides

        env_dir = tmp_path / "env-data"
        env_dir.mkdir()
        explicit_dir = tmp_path / "explicit"
        explicit_dir.mkdir()
        monkeypatch.setenv("LILBEE_DATA", str(env_dir))
        apply_overrides(data_dir=explicit_dir)
        assert cfg.data_root == explicit_dir

    def test_lilbee_data_env_ignored_when_global(self, monkeypatch, tmp_path):
        """--global takes precedence over LILBEE_DATA."""
        from lilbee.cli import apply_overrides
        from lilbee.core.system import default_data_dir

        monkeypatch.setenv("LILBEE_DATA", str(tmp_path / "should-be-ignored"))
        apply_overrides(use_global=True)
        assert cfg.data_root == default_data_dir()

    def test_generation_option_overrides(self):
        from lilbee.cli import apply_overrides

        apply_overrides(
            temperature=0.3,
            top_p=0.95,
            top_k_sampling=40,
            repeat_penalty=1.1,
            num_ctx=4096,
            seed=42,
        )
        assert cfg.temperature == 0.3
        assert cfg.top_p == 0.95
        assert cfg.top_k_sampling == 40
        assert cfg.repeat_penalty == 1.1
        assert cfg.num_ctx == 4096
        assert cfg.seed == 42

    def test_generation_option_none_is_noop(self):
        from lilbee.cli import apply_overrides

        cfg.temperature = 0.7
        apply_overrides(temperature=None)
        assert cfg.temperature == 0.7
        cfg.temperature = None

    def test_data_dir_overlays_per_root_config_toml(self, tmp_path):
        """A per-vault config.toml in the data-dir must be re-read when --data-dir lands.

        Regression: cfg's scalar fields (chat_model, embedding_model, ...) were
        loaded from the global config.toml at module import time, *before*
        ``--data-dir`` was processed. ``_apply_data_root`` only mutated path
        attrs, so per-vault settings were silently ignored at startup.
        """
        from lilbee.cli import apply_overrides

        # Stale "global" defaults already on cfg at import time.
        cfg.chat_model = "ollama/stale-global:latest"
        cfg.embedding_model = "ollama/stale-embed:latest"

        # Fresh per-vault config.toml.
        (tmp_path / "config.toml").write_text(
            'chat_model = "ollama/qwen3:4b"\nembedding_model = "ollama/nomic-embed-text:v1.5"\n'
        )

        apply_overrides(data_dir=tmp_path)

        assert cfg.chat_model == "ollama/qwen3:4b"
        assert cfg.embedding_model == "ollama/nomic-embed-text:v1.5"

    def test_data_dir_without_config_toml_leaves_cfg_unchanged(self, tmp_path):
        """An empty / missing config.toml must not stomp on existing cfg values."""
        from lilbee.cli import apply_overrides

        cfg.chat_model = "ollama/kept-from-import:latest"
        cfg.embedding_model = "ollama/kept-embed:latest"
        # tmp_path has no config.toml.

        apply_overrides(data_dir=tmp_path)

        assert cfg.chat_model == "ollama/kept-from-import:latest"
        assert cfg.embedding_model == "ollama/kept-embed:latest"

    def test_data_dir_overlay_covers_writable_scalar_fields(self, tmp_path):
        """Writable scalar fields (e.g. temperature, top_k) overlay too, not just models."""
        from lilbee.cli import apply_overrides

        cfg.temperature = 0.9
        cfg.top_k = 5

        (tmp_path / "config.toml").write_text('temperature = "0.2"\ntop_k = "20"\n')

        apply_overrides(data_dir=tmp_path)

        assert cfg.temperature == 0.2
        assert cfg.top_k == 20

    def test_use_global_overlays_global_config_toml(self, tmp_path, monkeypatch):
        """--global must also re-read the global root's config.toml."""
        from lilbee.cli import apply_overrides

        cfg.chat_model = "ollama/stale:latest"
        # Redirect default_data_dir to a tmp path with a known config.toml.
        fake_global = tmp_path / "global"
        fake_global.mkdir()
        (fake_global / "config.toml").write_text('chat_model = "ollama/from-global:latest"\n')
        monkeypatch.setattr("lilbee.core.system.default_data_dir", lambda: fake_global)

        apply_overrides(use_global=True)

        assert cfg.data_root == fake_global
        assert cfg.chat_model == "ollama/from-global:latest"

    def test_lilbee_data_env_overlays_config_toml(self, tmp_path, monkeypatch):
        """The LILBEE_DATA env-var path must also overlay its config.toml."""
        from lilbee.cli import apply_overrides

        cfg.chat_model = "ollama/stale:latest"
        env_dir = tmp_path / "env-data"
        env_dir.mkdir()
        (env_dir / "config.toml").write_text('chat_model = "ollama/from-env:latest"\n')
        monkeypatch.setenv("LILBEE_DATA", str(env_dir))

        apply_overrides()

        assert cfg.data_root == env_dir
        assert cfg.chat_model == "ollama/from-env:latest"

    def test_apply_data_root_exports_lilbee_data_env(self, tmp_path, monkeypatch):
        """``_apply_data_root`` exports ``LILBEE_DATA`` so workers can find log files.

        Worker subprocesses (multiprocessing.spawn) re-import lilbee in a
        fresh process with no inherited cfg state, so without this export
        the worker's ``configure_worker_logging`` and the supervisor's
        ``_worker_log_path`` would silently skip log-file creation when a
        user invoked lilbee with ``--data-dir`` instead of via the env.
        On Windows this turns an embed-worker heap-corruption crash into
        an opaque "subprocess exited unexpectedly" with no trail.
        """
        from lilbee.cli import apply_overrides

        monkeypatch.delenv("LILBEE_DATA", raising=False)
        apply_overrides(data_dir=tmp_path)
        assert os.environ.get("LILBEE_DATA") == str(tmp_path)

    def test_apply_data_root_exports_lilbee_data_env_global(self, tmp_path, monkeypatch):
        """``--global`` also exports ``LILBEE_DATA`` for worker visibility."""
        from lilbee.cli import apply_overrides

        fake_global = tmp_path / "global"
        fake_global.mkdir()
        monkeypatch.setattr("lilbee.core.system.default_data_dir", lambda: fake_global)
        monkeypatch.delenv("LILBEE_DATA", raising=False)

        apply_overrides(use_global=True)
        assert os.environ.get("LILBEE_DATA") == str(fake_global)

    def test_data_dir_overlay_skips_unknown_keys(self, tmp_path):
        """Stale or unrecognised keys in config.toml don't blow up startup."""
        from lilbee.cli import apply_overrides

        cfg.chat_model = "ollama/kept:latest"
        (tmp_path / "config.toml").write_text(
            'chat_model = "ollama/from-vault:latest"\ntotally_unknown_key = "garbage"\n'
        )

        apply_overrides(data_dir=tmp_path)

        assert cfg.chat_model == "ollama/from-vault:latest"
        assert not hasattr(cfg, "totally_unknown_key")

    def test_data_dir_overlay_logs_and_skips_invalid_value(self, tmp_path, caplog):
        """A malformed persisted value is logged and skipped, not raised."""
        from lilbee.cli import apply_overrides

        cfg.top_k = 7
        # top_k is int >= 1; "not-an-int" can't coerce.
        (tmp_path / "config.toml").write_text('top_k = "not-an-int"\n')

        with caplog.at_level(logging.WARNING):
            apply_overrides(data_dir=tmp_path)

        assert cfg.top_k == 7
        assert any("top_k" in rec.message for rec in caplog.records)

    def test_data_dir_overlay_handles_unreadable_config_toml(self, tmp_path, monkeypatch, caplog):
        """A read failure on config.toml is logged and treated as 'no overlay'."""
        from lilbee.cli import apply_overrides
        from lilbee.core import settings as settings_mod

        cfg.chat_model = "ollama/kept:latest"

        def _boom(_root):
            raise OSError("simulated read failure")

        monkeypatch.setattr(settings_mod, "load", _boom)

        with caplog.at_level(logging.WARNING):
            apply_overrides(data_dir=tmp_path)

        assert cfg.chat_model == "ollama/kept:latest"
        assert any("config.toml" in rec.message for rec in caplog.records)


class TestGlobalFlag:
    """Tests for the --global / -g CLI flag."""

    def test_global_flag_on_status(self):
        from lilbee.core.system import default_data_dir

        result = runner.invoke(app, ["--json", "status", "--global"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        expected = str(default_data_dir() / "documents")
        assert data["config"]["documents_dir"] == expected

    def test_global_short_flag_on_status(self):
        from lilbee.core.system import default_data_dir

        result = runner.invoke(app, ["--json", "status", "-g"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        expected = str(default_data_dir() / "documents")
        assert data["config"]["documents_dir"] == expected

    def test_global_with_data_dir_errors(self, tmp_path):
        result = runner.invoke(app, ["status", "--global", "--data-dir", str(tmp_path)])
        assert result.exit_code != 0

    def test_help_shows_global_flag(self):
        result = runner.invoke(app, ["status", "--help"])
        # Rich wraps "--global" with ANSI codes in CI, so match without the dashes
        assert "global" in result.output


class TestMainModule:
    def test_python_m_lilbee_runs(self):
        """Ensure `python -m lilbee` invokes the CLI app."""
        result = runner.invoke(app, ["--help"])
        assert result.exit_code == 0
        assert "lilbee" in result.output


# ---------------------------------------------------------------------------
# Slash-command tests
# ---------------------------------------------------------------------------


class TestDefaultRequiresTerminal:
    """Invoking `lilbee` with no subcommand requires a TTY."""

    def test_non_tty_exits_with_error(self) -> None:
        result = runner.invoke(app, [])
        assert result.exit_code == 1

    def test_tty_launches_tui(self) -> None:
        """When stdin/stdout are TTYs, the default command launches TUI."""
        from lilbee.cli.tui import run_tui as original_run_tui

        called = []

        def fake_run_tui(**kwargs: object) -> None:
            called.append(kwargs)

        import lilbee.cli.tui

        lilbee.cli.tui.run_tui = fake_run_tui  # type: ignore[assignment]
        try:
            # The runner's isatty check happens on sys.stdin, so we need to
            # make isatty return True on whatever stream the code actually reads.
            import sys

            orig_sin = sys.stdin.isatty
            orig_sout = sys.stdout.isatty
            sys.stdin.isatty = lambda: True  # type: ignore[method-assign]
            sys.stdout.isatty = lambda: True  # type: ignore[method-assign]
            try:
                from lilbee.cli.app import _default

                ctx = mock.MagicMock()
                ctx.invoked_subcommand = None
                _default(
                    ctx,
                    data_dir=None,
                    model=None,
                    json_output=False,
                    use_global=False,
                    log_level=None,
                    show_version=False,
                )
            finally:
                sys.stdin.isatty = orig_sin  # type: ignore[method-assign]
                sys.stdout.isatty = orig_sout  # type: ignore[method-assign]
        finally:
            lilbee.cli.tui.run_tui = original_run_tui
        assert called == [{}]


class TestChatLaunchesTui:
    """The chat subcommand launches TUI when on a TTY."""

    def test_chat_tty_launches_tui(self) -> None:
        from lilbee.cli.tui import run_tui as original_run_tui

        called = []

        def fake_run_tui(**kwargs: object) -> None:
            called.append(kwargs)

        import lilbee.cli.tui

        lilbee.cli.tui.run_tui = fake_run_tui  # type: ignore[assignment]
        try:
            with mock.patch("sys.stdin") as mock_in, mock.patch("sys.stdout") as mock_out:
                mock_in.isatty.return_value = True
                mock_out.isatty.return_value = True
                from lilbee.cli.commands.search_chat import chat

                # Call with minimal defaults
                chat(
                    data_dir=None,
                    model=None,
                    use_global=False,
                    temperature=None,
                    top_p=None,
                    top_k_sampling=None,
                    repeat_penalty=None,
                    num_ctx=None,
                    seed=None,
                )
        finally:
            lilbee.cli.tui.run_tui = original_run_tui
        assert called == [{}]


# ---------------------------------------------------------------------------
# Completer tests
# ---------------------------------------------------------------------------


class TestListInstalledModels:
    """Test list_installed_models helper."""

    _CHAT_REPO = "Qwen/Qwen3-8B-GGUF"
    _CHAT_FILE = "Qwen3-8B-Q4_K_M.gguf"
    _CHAT_REF = f"{_CHAT_REPO}/{_CHAT_FILE}"

    def _manifest(self, hf_repo: str, gguf_filename: str, task: str):
        from lilbee.modelhub.registry import ModelManifest

        return ModelManifest(
            hf_repo=hf_repo,
            gguf_filename=gguf_filename,
            size_bytes=0,
            task=task,
            downloaded_at="",
        )

    def _remote(self, name: str, task: str):
        from lilbee.modelhub.model_manager import RemoteModel

        return RemoteModel(name=name, task=task, family="", parameter_size="")

    def test_returns_only_chat_task_models(self):
        with (
            mock.patch("lilbee.modelhub.registry.ModelRegistry.list_installed") as mock_reg,
            mock.patch("lilbee.modelhub.model_manager.classify_all_remote_models") as mock_remote,
        ):
            mock_reg.return_value = [self._manifest(self._CHAT_REPO, self._CHAT_FILE, "chat")]
            mock_remote.return_value = []
            assert list_installed_models() == [self._CHAT_REF]

    def test_returns_empty_on_error(self):
        with mock.patch(
            "lilbee.modelhub.registry.ModelRegistry.list_installed",
            side_effect=ConnectionError("not running"),
        ):
            assert list_installed_models() == []

    def test_excludes_non_chat_registry_tasks(self):
        with (
            mock.patch("lilbee.modelhub.registry.ModelRegistry.list_installed") as mock_reg,
            mock.patch("lilbee.modelhub.model_manager.classify_all_remote_models") as mock_remote,
        ):
            mock_reg.return_value = [
                self._manifest(self._CHAT_REPO, self._CHAT_FILE, "chat"),
                self._manifest(
                    "nomic-ai/nomic-embed-text-v1.5-GGUF", "nomic-Q4_K_M.gguf", "embedding"
                ),
                self._manifest("noctrex/LightOnOCR-2-1B-GGUF", "ocr-Q4_K_M.gguf", "vision"),
                self._manifest("gpustack/bge-reranker-v2-m3-GGUF", "bge-Q4_K_M.gguf", "rerank"),
            ]
            mock_remote.return_value = []
            result = list_installed_models()
            assert result == [self._CHAT_REF]

    def test_excludes_non_chat_remote_tasks(self):
        from lilbee.catalog.types import ModelTask

        with (
            mock.patch("lilbee.modelhub.registry.ModelRegistry.list_installed") as mock_reg,
            mock.patch("lilbee.modelhub.model_manager.classify_all_remote_models") as mock_remote,
        ):
            mock_reg.return_value = []
            mock_remote.return_value = [
                self._remote("ollama/llama3:latest", ModelTask.CHAT),
                self._remote("ollama/nomic-embed-text:v1.5", ModelTask.EMBEDDING),
                self._remote("ollama/lightonocr:2-1b", ModelTask.VISION),
                self._remote("ollama/bge-reranker:v2-m3", ModelTask.RERANK),
            ]
            result = list_installed_models()
            assert result == ["ollama/llama3:latest"]

    def test_dedupes_native_and_remote_overlap(self):
        with (
            mock.patch("lilbee.modelhub.registry.ModelRegistry.list_installed") as mock_reg,
            mock.patch("lilbee.modelhub.model_manager.classify_all_remote_models") as mock_remote,
        ):
            mock_reg.return_value = [self._manifest(self._CHAT_REPO, self._CHAT_FILE, "chat")]
            # Remote backend reports the same canonical ref string.
            mock_remote.return_value = [self._remote(self._CHAT_REF, "chat")]
            assert list_installed_models() == [self._CHAT_REF]


def _search_chunk(**overrides: object) -> SearchChunk:
    defaults: dict[str, object] = {
        "source": "a.pdf",
        "content_type": "pdf",
        "page_start": 0,
        "page_end": 0,
        "line_start": 0,
        "line_end": 0,
        "chunk": "hi",
        "chunk_index": 0,
        "vector": [0.1, 0.2],
    }
    return SearchChunk(**(defaults | overrides))


class TestCleanResult:
    def test_strips_vector(self):
        result = clean_result(_search_chunk(distance=0.5))
        assert "vector" not in result
        assert result["source"] == "a.pdf"

    def test_has_distance(self):
        result = clean_result(_search_chunk(distance=0.42))
        assert result["distance"] == 0.42

    def test_excludes_none_scores(self):
        result = clean_result(_search_chunk(distance=0.5))
        assert "relevance_score" not in result

    def test_has_relevance_score(self):
        result = clean_result(_search_chunk(relevance_score=0.85))
        assert result["relevance_score"] == 0.85
        assert "vector" not in result
        assert "distance" not in result


class TestResolveVaultPath:
    """``resolve_vault_path`` returns a vault-relative string, or None.

    Covers the four short-circuit conditions the Obsidian plugin relies
    on for its deep-link vs preview-modal fallback.
    """

    def test_returns_none_when_vault_base_unset(self, tmp_path, monkeypatch):
        from lilbee.app.search import resolve_vault_path

        monkeypatch.setattr(cfg, "vault_base", None)
        monkeypatch.setattr(cfg, "documents_dir", tmp_path)
        assert resolve_vault_path("anything.md") is None

    def test_returns_none_when_documents_dir_outside_vault(self, tmp_path, monkeypatch):
        """documents_dir not nested under vault_base → None, no stamping."""
        from lilbee.app.search import resolve_vault_path

        vault = tmp_path / "vault"
        vault.mkdir()
        elsewhere = tmp_path / "elsewhere"
        elsewhere.mkdir()
        monkeypatch.setattr(cfg, "vault_base", vault)
        monkeypatch.setattr(cfg, "documents_dir", elsewhere)
        assert resolve_vault_path("doc.md") is None

    def test_returns_none_when_file_does_not_exist(self, tmp_path, monkeypatch):
        """Stale source name (file deleted after indexing) → None."""
        from lilbee.app.search import resolve_vault_path

        vault = tmp_path / "vault"
        docs = vault / "lilbee" / "documents"
        docs.mkdir(parents=True)
        monkeypatch.setattr(cfg, "vault_base", vault)
        monkeypatch.setattr(cfg, "documents_dir", docs)
        assert resolve_vault_path("missing.md") is None

    def test_returns_vault_relative_path_when_file_exists(self, tmp_path, monkeypatch):
        """documents_dir inside vault + file on disk → vault-relative posix path."""
        from lilbee.app.search import resolve_vault_path

        vault = tmp_path / "vault"
        docs = vault / "lilbee" / "documents"
        docs.mkdir(parents=True)
        (docs / "doc.md").write_text("hello")
        monkeypatch.setattr(cfg, "vault_base", vault)
        monkeypatch.setattr(cfg, "documents_dir", docs)
        result = resolve_vault_path("doc.md")
        assert result is not None
        # Path separator is platform-native but both components must be present.
        assert "doc.md" in result
        assert "documents" in result


class TestCleanResultVaultPath:
    """clean_result stamps ``vault_path`` when resolve_vault_path returns a value."""

    def test_stamps_vault_path_when_resolvable(self, tmp_path, monkeypatch):
        vault = tmp_path / "vault"
        docs = vault / "lilbee" / "documents"
        docs.mkdir(parents=True)
        (docs / "a.pdf").write_text("stub")
        monkeypatch.setattr(cfg, "vault_base", vault)
        monkeypatch.setattr(cfg, "documents_dir", docs)
        result = clean_result(_search_chunk(distance=0.5))
        assert "vault_path" in result
        assert "a.pdf" in result["vault_path"]


class TestJsonFlag:
    def test_json_no_subcommand_returns_error(self):
        result = runner.invoke(app, ["--json"])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "error" in data
        assert "terminal" in data["error"].lower()

    def test_short_j_flag_works(self):
        result = runner.invoke(app, ["-j"])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "error" in data


# ---------------------------------------------------------------------------
# Search command tests (Task 2)
# ---------------------------------------------------------------------------

_MOCK_SEARCH_RESULTS = [
    SearchChunk(
        source="manual.pdf",
        content_type="pdf",
        page_start=5,
        page_end=5,
        line_start=0,
        line_end=0,
        chunk="The engine oil capacity is 5 quarts.",
        chunk_index=0,
        distance=0.25,
        vector=[0.1] * 768,
    ),
]


class TestSearch:
    def test_search_json_with_results(self, mock_svc):
        mock_svc.searcher.search.return_value = _MOCK_SEARCH_RESULTS
        result = runner.invoke(app, ["--json", "search", "engine oil"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "search"
        assert data["query"] == "engine oil"
        assert len(data["results"]) == 1
        assert "vector" not in data["results"][0]
        assert "distance" in data["results"][0]

    def test_search_json_empty_results(self, mock_svc):
        mock_svc.searcher.search.return_value = []
        result = runner.invoke(app, ["--json", "search", "nothing"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["results"] == []

    def test_search_human_output(self, mock_svc):
        mock_svc.searcher.search.return_value = _MOCK_SEARCH_RESULTS
        result = runner.invoke(app, ["search", "engine oil"])
        assert result.exit_code == 0
        assert "manual.pdf" in result.output

    def test_search_human_truncates_long_chunks(self, mock_svc):
        mock_svc.searcher.search.return_value = [
            _MOCK_SEARCH_RESULTS[0].model_copy(update={"chunk": "x" * 100})
        ]
        result = runner.invoke(app, ["search", "test"])
        assert result.exit_code == 0
        assert result.output.count("x") < 100

    def test_search_human_no_results(self, mock_svc):
        mock_svc.searcher.search.return_value = []
        result = runner.invoke(app, ["search", "nothing"])
        assert result.exit_code == 0
        assert "No results found" in result.output

    def test_search_human_hybrid_shows_score(self, mock_svc):
        mock_svc.searcher.search.return_value = [
            _MOCK_SEARCH_RESULTS[0].model_copy(update={"relevance_score": 0.85, "distance": None})
        ]
        result = runner.invoke(app, ["search", "engine oil"])
        assert result.exit_code == 0
        assert "Score" in result.output
        assert "0.85" in result.output

    def test_search_json_hybrid_has_relevance_score(self, mock_svc):
        mock_svc.searcher.search.return_value = [
            _MOCK_SEARCH_RESULTS[0].model_copy(update={"relevance_score": 0.85, "distance": None})
        ]
        result = runner.invoke(app, ["--json", "search", "engine oil"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert "relevance_score" in data["results"][0]
        assert "distance" not in data["results"][0]

    def test_search_json_empty_query_error(self, mock_svc):
        result = runner.invoke(app, ["--json", "search", ""])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "error" in data
        mock_svc.searcher.search.assert_not_called()

    def test_search_scope_wiki_passes_wiki_chunk_type(self, mock_svc):
        mock_svc.searcher.search.return_value = []
        result = runner.invoke(app, ["search", "--scope", "wiki", "q"])
        assert result.exit_code == 0
        assert mock_svc.searcher.search.call_args.kwargs.get("chunk_type") == "wiki"

    def test_search_scope_raw_passes_raw_chunk_type(self, mock_svc):
        mock_svc.searcher.search.return_value = []
        result = runner.invoke(app, ["search", "--scope", "raw", "q"])
        assert result.exit_code == 0
        assert mock_svc.searcher.search.call_args.kwargs.get("chunk_type") == "raw"

    def test_search_default_scope_is_mixed_pool(self, mock_svc):
        """Omitting --scope means chunk_type=None (both)."""
        mock_svc.searcher.search.return_value = []
        result = runner.invoke(app, ["search", "q"])
        assert result.exit_code == 0
        assert mock_svc.searcher.search.call_args.kwargs.get("chunk_type") is None

    def test_search_invalid_scope_exits_nonzero(self, mock_svc):
        result = runner.invoke(app, ["search", "--scope", "bogus", "q"])
        assert result.exit_code != 0
        mock_svc.searcher.search.assert_not_called()

    def test_search_json_provider_error(self, mock_svc):
        mock_svc.searcher.search.side_effect = RuntimeError("provider down")
        result = runner.invoke(app, ["--json", "search", "test"])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "provider down" in data["error"]

    def test_search_human_empty_query_error(self, mock_svc):
        result = runner.invoke(app, ["search", ""])
        assert result.exit_code == 1
        assert "empty" in result.output.lower()

    def test_search_human_provider_error(self, mock_svc):
        mock_svc.searcher.search.side_effect = RuntimeError("connection refused")
        result = runner.invoke(app, ["search", "test"])
        assert result.exit_code == 1
        assert "connection refused" in result.output


# ---------------------------------------------------------------------------
# JSON status tests (Task 3)
# ---------------------------------------------------------------------------


class TestVersionFlag:
    """Test --version / -V CLI flag."""

    def test_version_flag(self):
        result = runner.invoke(app, ["--version"])
        assert result.exit_code == 0
        assert "lilbee" in result.output
        assert get_version() in result.output

    def test_short_version_flag(self):
        result = runner.invoke(app, ["-V"])
        assert result.exit_code == 0
        assert get_version() in result.output


class TestConfigLoadWarning:
    """When persisted config is incompatible, the CLI surfaces a stderr warning."""

    def _invoke_default(self, *, json_output: bool, monkeypatch) -> str:
        """Call the _default callback directly, returning what it wrote to stderr."""
        import io
        import sys

        from typer import Context
        from typer.core import TyperCommand

        from lilbee.cli.app import _default

        app_module = sys.modules["lilbee.cli.app"]
        monkeypatch.setattr(app_module, "config_load_error", ValueError("stale-ref-xyz"))

        captured = io.StringIO()
        monkeypatch.setattr("sys.stderr", captured)
        # Build a minimal Typer/click Context with a recognised subcommand so
        # the callback skips the interactive-chat branch.
        ctx = Context(TyperCommand(name="status", callback=lambda: None))
        ctx.invoked_subcommand = "status"
        _default(
            ctx,
            data_dir=None,
            model=None,
            json_output=json_output,
            use_global=False,
            log_level=None,
            show_version=False,
        )
        return captured.getvalue()

    def test_warning_printed_to_stderr_when_config_load_failed(self, monkeypatch):
        stderr = self._invoke_default(json_output=False, monkeypatch=monkeypatch)
        assert "Warning: persisted config" in stderr
        assert "stale-ref-xyz" in stderr

    def test_warning_suppressed_in_json_mode(self, monkeypatch):
        stderr = self._invoke_default(json_output=True, monkeypatch=monkeypatch)
        assert "Warning: persisted config" not in stderr


class TestRemove:
    """Test remove command."""

    def test_remove_existing_source(self, isolated_env, mock_svc):
        from lilbee.data.store import RemoveResult

        mock_svc.store.remove_documents.return_value = RemoveResult(
            removed=["test.pdf"], not_found=[]
        )
        result = runner.invoke(app, ["remove", "test.pdf"])
        assert result.exit_code == 0
        assert "Removed" in result.output
        assert "test.pdf" in result.output

    def test_remove_nonexistent_source(self, mock_svc):
        from lilbee.data.store import RemoveResult

        mock_svc.store.remove_documents.return_value = RemoveResult(
            removed=[], not_found=["nope.pdf"]
        )
        result = runner.invoke(app, ["remove", "nope.pdf"])
        assert result.exit_code == 1
        assert "Not found" in result.output

    def test_remove_multiple_sources(self, isolated_env, mock_svc):
        from lilbee.data.store import RemoveResult

        mock_svc.store.remove_documents.return_value = RemoveResult(
            removed=["a.pdf", "b.pdf"], not_found=[]
        )
        result = runner.invoke(app, ["remove", "a.pdf", "b.pdf"])
        assert result.exit_code == 0
        assert "a.pdf" in result.output
        assert "b.pdf" in result.output

    def test_remove_mixed_existing_and_not(self, isolated_env, mock_svc):
        from lilbee.data.store import RemoveResult

        mock_svc.store.remove_documents.return_value = RemoveResult(
            removed=["a.pdf"], not_found=["nope.pdf"]
        )
        result = runner.invoke(app, ["remove", "a.pdf", "nope.pdf"])
        assert result.exit_code == 0
        assert "Removed" in result.output
        assert "Not found" in result.output

    def test_remove_with_delete_flag(self, isolated_env, mock_svc):
        from lilbee.data.store import RemoveResult

        doc = cfg.documents_dir / "test.txt"
        doc.write_text("content")
        mock_svc.store.remove_documents.return_value = RemoveResult(
            removed=["test.txt"], not_found=[]
        )
        mock_svc.store.remove_documents.side_effect = lambda names, **kw: (
            doc.unlink() or RemoveResult(removed=["test.txt"], not_found=[])
            if kw.get("delete_files")
            else RemoveResult(removed=["test.txt"], not_found=[])
        )
        result = runner.invoke(app, ["remove", "--delete", "test.txt"])
        assert result.exit_code == 0
        assert not doc.exists()

    def test_remove_json(self, isolated_env, mock_svc):
        from lilbee.data.store import RemoveResult

        mock_svc.store.remove_documents.return_value = RemoveResult(
            removed=["test.pdf"], not_found=[]
        )
        result = runner.invoke(app, ["--json", "remove", "test.pdf"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "remove"
        assert "test.pdf" in data["removed"]

    def test_remove_json_not_found_exits_1(self, mock_svc):
        from lilbee.data.store import RemoveResult

        mock_svc.store.remove_documents.return_value = RemoveResult(
            removed=[], not_found=["nope.pdf"]
        )
        result = runner.invoke(app, ["--json", "remove", "nope.pdf"])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert data["removed"] == []
        assert "nope.pdf" in data["not_found"]

    def test_remove_delete_path_traversal_skips(self, isolated_env, mock_svc):
        """Path traversal in name with --delete is caught and skipped."""
        from lilbee.data.store import RemoveResult

        traversal_name = "../../etc/passwd"
        mock_svc.store.remove_documents.return_value = RemoveResult(
            removed=[traversal_name], not_found=[]
        )
        result = runner.invoke(app, ["remove", "--delete", traversal_name])
        assert result.exit_code == 0


class TestChunks:
    """Test chunks command."""

    def test_chunks_nonexistent_source(self, mock_svc):
        mock_svc.store.get_sources.return_value = []
        result = runner.invoke(app, ["chunks", "nope.pdf"])
        assert result.exit_code == 1
        assert "Source not found" in result.output

    def test_chunks_nonexistent_json(self, mock_svc):
        mock_svc.store.get_sources.return_value = []
        result = runner.invoke(app, ["--json", "chunks", "nope.pdf"])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "error" in data

    def test_chunks_with_source(self, isolated_env, mock_svc):
        mock_svc.store.get_sources.return_value = [
            {
                "filename": "test.txt",
                "file_hash": "abc123",
                "chunk_count": 2,
                "ingested_at": "2026-01-01T00:00:00",
            },
        ]
        mock_svc.store.get_chunks_by_source.return_value = [
            SearchChunk(
                source="test.txt",
                content_type="text",
                page_start=0,
                page_end=0,
                line_start=0,
                line_end=0,
                chunk="First chunk content",
                chunk_index=0,
                vector=[0.1] * 768,
            ),
            SearchChunk(
                source="test.txt",
                content_type="text",
                page_start=0,
                page_end=0,
                line_start=0,
                line_end=0,
                chunk="Second chunk content",
                chunk_index=1,
                vector=[0.2] * 768,
            ),
        ]
        result = runner.invoke(app, ["chunks", "test.txt"])
        assert result.exit_code == 0
        assert "2 chunks" in result.output
        assert "First chunk" in result.output

    def test_chunks_truncates_long_chunk(self, isolated_env, mock_svc):
        mock_svc.store.get_sources.return_value = [
            {
                "filename": "long.txt",
                "file_hash": "abc123",
                "chunk_count": 1,
                "ingested_at": "2026-01-01T00:00:00",
            },
        ]
        mock_svc.store.get_chunks_by_source.return_value = [
            SearchChunk(
                source="long.txt",
                content_type="text",
                page_start=0,
                page_end=0,
                line_start=0,
                line_end=0,
                chunk="x" * 200,
                chunk_index=0,
                vector=[0.1] * 768,
            ),
        ]
        result = runner.invoke(app, ["chunks", "long.txt"])
        assert result.exit_code == 0
        assert "..." in result.output

    def test_chunks_json(self, isolated_env, mock_svc):
        mock_svc.store.get_sources.return_value = [
            {
                "filename": "test.txt",
                "file_hash": "abc123",
                "chunk_count": 1,
                "ingested_at": "2026-01-01T00:00:00",
            },
        ]
        mock_svc.store.get_chunks_by_source.return_value = [
            SearchChunk(
                source="test.txt",
                content_type="text",
                page_start=0,
                page_end=0,
                line_start=0,
                line_end=0,
                chunk="Chunk content",
                chunk_index=0,
                vector=[0.1] * 768,
            ),
        ]
        result = runner.invoke(app, ["--json", "chunks", "test.txt"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "chunks"
        assert data["source"] == "test.txt"
        assert len(data["chunks"]) == 1
        assert "vector" not in data["chunks"][0]


class TestReset:
    """Test reset command."""

    def test_reset_deletes_everything(self, isolated_env):
        """With --yes, both dirs are cleared."""
        cfg.data_dir.mkdir(parents=True, exist_ok=True)
        (cfg.documents_dir / "doc.txt").write_text("content")
        (cfg.data_dir / "db_file").write_text("data")

        result = runner.invoke(app, ["reset", "--yes"])
        assert result.exit_code == 0
        assert "Reset complete" in result.output
        assert list(cfg.documents_dir.iterdir()) == []
        assert list(cfg.data_dir.iterdir()) == []

    def test_reset_without_yes_prompts(self, isolated_env):
        """Without --yes, prompts and aborts on 'n'."""
        (cfg.documents_dir / "doc.txt").write_text("content")

        result = runner.invoke(app, ["reset"], input="n\n")
        assert result.exit_code == 0
        assert "Aborted" in result.output
        # File should still exist
        assert (cfg.documents_dir / "doc.txt").exists()

    def test_reset_without_yes_confirms(self, isolated_env):
        """Without --yes, confirming with 'y' deletes everything."""
        cfg.data_dir.mkdir(parents=True, exist_ok=True)
        (cfg.documents_dir / "doc.txt").write_text("content")

        result = runner.invoke(app, ["reset"], input="y\n")
        assert result.exit_code == 0
        assert "Reset complete" in result.output
        assert list(cfg.documents_dir.iterdir()) == []

    def test_reset_json_output(self, isolated_env):
        """JSON mode returns structured output."""
        cfg.data_dir.mkdir(parents=True, exist_ok=True)
        (cfg.documents_dir / "doc.txt").write_text("content")

        result = runner.invoke(app, ["--json", "reset", "--yes"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "reset"
        assert data["deleted_docs"] == 1

    def test_reset_json_without_yes_errors(self):
        """JSON mode without --yes returns error."""
        result = runner.invoke(app, ["--json", "reset"])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "error" in data

    def test_reset_empty_dirs(self, isolated_env):
        """Reset on already-empty dirs doesn't crash."""
        result = runner.invoke(app, ["reset", "--yes"])
        assert result.exit_code == 0
        assert "Reset complete" in result.output
        assert "0 document(s)" in result.output

    def test_reset_drops_cached_store(self, isolated_env):
        """reset must invalidate the cached Store so a follow-up sees the empty data dir."""
        cfg.data_dir.mkdir(parents=True, exist_ok=True)
        (cfg.documents_dir / "doc.txt").write_text("content")

        with mock.patch("lilbee.cli.commands.meta.reset_store") as mock_reset_store:
            result = runner.invoke(app, ["reset", "--yes"])
            assert result.exit_code == 0
            mock_reset_store.assert_called_once()

    def test_reset_with_subdirectories(self, isolated_env):
        """Reset removes subdirectories too."""
        sub = cfg.documents_dir / "subdir"
        sub.mkdir()
        (sub / "nested.txt").write_text("nested content")

        result = runner.invoke(app, ["reset", "--yes"])
        assert result.exit_code == 0
        assert list(cfg.documents_dir.iterdir()) == []

    def test_reset_data_dir_with_subdirectories(self, isolated_env):
        """Reset removes subdirectories in data dir too."""
        cfg.data_dir.mkdir(parents=True, exist_ok=True)
        sub = cfg.data_dir / "lancedb"
        sub.mkdir()
        (sub / "table.lance").write_text("lance data")

        result = runner.invoke(app, ["reset", "--yes"])
        assert result.exit_code == 0
        assert list(cfg.data_dir.iterdir()) == []

    def test_reset_skips_locked_file(self, isolated_env):
        """Locked file is skipped, other files still deleted."""
        (cfg.documents_dir / "ok.txt").write_text("deletable")
        locked = cfg.documents_dir / "locked.exe"
        locked.write_text("in use")

        original_unlink = Path.unlink

        def _unlink_raises(self, *args, **kwargs):
            if self.name == "locked.exe":
                raise PermissionError("[WinError 5] Access is denied")
            return original_unlink(self, *args, **kwargs)

        with mock.patch.object(Path, "unlink", _unlink_raises):
            result = runner.invoke(app, ["reset", "--yes"])

        assert result.exit_code == 0
        assert "could not be deleted" in result.output
        remaining = [p.name for p in cfg.documents_dir.iterdir()]
        assert "locked.exe" in remaining
        assert "ok.txt" not in remaining

    def test_reset_skips_locked_directory(self, isolated_env):
        """Locked directory is skipped, other items still deleted."""
        (cfg.documents_dir / "ok.txt").write_text("deletable")
        locked_dir = cfg.documents_dir / "locked_dir"
        locked_dir.mkdir()
        (locked_dir / "file.txt").write_text("nested")

        original_rmtree = shutil.rmtree

        def _rmtree_raises(path, *args, **kwargs):
            if Path(path).name == "locked_dir":
                raise OSError("[WinError 32] The process cannot access the file")
            return original_rmtree(path, *args, **kwargs)

        with mock.patch("lilbee.app.reset.shutil.rmtree", side_effect=_rmtree_raises):
            result = runner.invoke(app, ["reset", "--yes"])

        assert result.exit_code == 0
        assert "could not be deleted" in result.output
        remaining = [p.name for p in cfg.documents_dir.iterdir()]
        assert "locked_dir" in remaining
        assert "ok.txt" not in remaining

    def test_reset_reports_skipped_in_json(self, isolated_env):
        """JSON output includes skipped files."""
        locked = cfg.documents_dir / "locked.exe"
        locked.write_text("in use")

        original_unlink = Path.unlink

        def _unlink_raises(self, *args, **kwargs):
            if self.name == "locked.exe":
                raise PermissionError("[WinError 5] Access is denied")
            return original_unlink(self, *args, **kwargs)

        with mock.patch.object(Path, "unlink", _unlink_raises):
            result = runner.invoke(app, ["--json", "reset", "--yes"])

        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["deleted_docs"] == 0
        assert len(data["skipped"]) == 1
        assert "locked.exe" in data["skipped"][0]

    def test_reset_all_locked_reports_all_skipped(self, isolated_env):
        """When all files are locked, nothing is deleted and all are reported."""
        (cfg.documents_dir / "a.txt").write_text("locked")
        (cfg.documents_dir / "b.txt").write_text("locked")

        def _unlink_always_raises(self, *args, **kwargs):
            raise PermissionError("Access is denied")

        with mock.patch.object(Path, "unlink", _unlink_always_raises):
            result = runner.invoke(app, ["--json", "reset", "--yes"])

        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["deleted_docs"] == 0
        assert len(data["skipped"]) == 2


class TestInit:
    def test_init_creates_structure(self, tmp_path):
        with mock.patch("pathlib.Path.cwd", return_value=tmp_path):
            result = runner.invoke(app, ["init"])
        assert result.exit_code == 0
        root = tmp_path / ".lilbee"
        assert root.is_dir()
        assert (root / "documents").is_dir()
        assert (root / "data").is_dir()
        assert (root / ".gitignore").read_text() == "data/\n"
        assert "Initialized" in result.output

    def test_init_already_exists(self, tmp_path):
        (tmp_path / ".lilbee").mkdir()
        with mock.patch("pathlib.Path.cwd", return_value=tmp_path):
            result = runner.invoke(app, ["init"])
        assert result.exit_code == 0
        assert "Already initialized" in result.output

    def test_init_json_created(self, tmp_path):
        with mock.patch("pathlib.Path.cwd", return_value=tmp_path):
            result = runner.invoke(app, ["--json", "init"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "init"
        assert data["created"] is True
        assert ".lilbee" in data["path"]

    def test_init_json_already_exists(self, tmp_path):
        (tmp_path / ".lilbee").mkdir()
        with mock.patch("pathlib.Path.cwd", return_value=tmp_path):
            result = runner.invoke(app, ["--json", "init"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["created"] is False


class TestVersion:
    def test_version_human(self):
        result = runner.invoke(app, ["version"])
        assert result.exit_code == 0
        assert "lilbee" in result.output
        assert get_version() in result.output

    def test_version_json(self):
        result = runner.invoke(app, ["--json", "version"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "version"
        assert data["version"] == get_version()


class TestGetVersion:
    def test_returns_string(self):
        ver = get_version()
        assert isinstance(ver, str)
        assert len(ver) > 0


class TestStatusJson:
    def test_status_json_empty(self):
        result = runner.invoke(app, ["--json", "status"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "status"
        assert "config" in data
        assert data["sources"] == []
        assert data["total_chunks"] == 0

    def test_status_json_with_sources(self, isolated_env, mock_svc):
        mock_svc.store.get_sources.return_value = [
            {
                "filename": "test.pdf",
                "file_hash": "abc123hash",
                "chunk_count": 10,
                "ingested_at": "2026-01-01T00:00:00",
            }
        ]
        result = runner.invoke(app, ["--json", "status"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert len(data["sources"]) == 1
        assert data["sources"][0]["filename"] == "test.pdf"
        assert data["total_chunks"] == 10
        assert "documents_dir" in data["config"]

    def test_status_json_includes_enable_ocr_when_set(self):
        cfg.enable_ocr = True
        result = runner.invoke(app, ["--json", "status"])
        data = json.loads(result.output.strip())
        assert data["config"]["enable_ocr"] is True

    def test_status_json_excludes_enable_ocr_when_none(self):
        cfg.enable_ocr = None
        result = runner.invoke(app, ["--json", "status"])
        data = json.loads(result.output.strip())
        assert "enable_ocr" not in data["config"]


# ---------------------------------------------------------------------------
# JSON sync/rebuild/add tests (Task 4)
# ---------------------------------------------------------------------------


class TestSyncJson:
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_sync_json_empty(self, mock_sync):
        result = runner.invoke(app, ["--json", "sync"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "sync"
        assert data["added"] == []
        assert data["unchanged"] == 0

    @mock.patch(
        "lilbee.data.ingest.sync",
        new_callable=AsyncMock,
        return_value=SyncResult(added=["new.txt"], removed=["old.txt"], unchanged=2),
    )
    def test_sync_json_with_changes(self, mock_sync):
        result = runner.invoke(app, ["--json", "sync"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["added"] == ["new.txt"]
        assert data["removed"] == ["old.txt"]
        assert data["unchanged"] == 2


class TestRebuildJson:
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_rebuild_json(self, mock_sync, isolated_env):
        result = runner.invoke(app, ["--json", "rebuild"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "rebuild"
        assert "ingested" in data


class TestAddJson:
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_add_json(self, mock_sync, isolated_env, tmp_path):
        src = tmp_path / "source" / "manual.txt"
        src.parent.mkdir()
        src.write_text("Engine oil capacity is 5 quarts.")
        result = runner.invoke(app, ["--json", "add", str(src)])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "add"
        assert "manual.txt" in data["copied"]
        assert "sync" in data

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_add_json_skipped_no_stdout_pollution(self, mock_sync, isolated_env, tmp_path):
        """JSON add with existing file returns skipped list, no console warnings."""
        src = tmp_path / "source" / "notes.txt"
        src.parent.mkdir()
        src.write_text("some content")
        # Pre-populate documents dir so file is skipped
        cfg.documents_dir.mkdir(parents=True, exist_ok=True)
        (cfg.documents_dir / "notes.txt").write_text("old content")
        result = runner.invoke(app, ["--json", "add", str(src)])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["copied"] == []
        assert "notes.txt" in data["skipped"]
        assert "Warning" not in result.output


# ---------------------------------------------------------------------------
# JSON ask tests (Task 5)
# ---------------------------------------------------------------------------


class TestAskJson:
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_json(self, mock_sync, mock_svc):
        from lilbee.retrieval.query import AskResult

        mock_svc.searcher.ask_raw.return_value = AskResult(
            answer="5 quarts",
            sources=[
                SearchChunk(
                    source="manual.pdf",
                    content_type="pdf",
                    page_start=1,
                    page_end=1,
                    line_start=0,
                    line_end=0,
                    chunk="oil",
                    chunk_index=0,
                    distance=0.3,
                    vector=[0.1],
                )
            ],
        )
        result = runner.invoke(app, ["--json", "ask", "oil capacity?"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "ask"
        assert data["question"] == "oil capacity?"
        assert data["answer"] == "5 quarts"
        assert len(data["sources"]) == 1
        assert "vector" not in data["sources"][0]
        assert "distance" in data["sources"][0]

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_json_no_results(self, mock_sync, mock_svc):
        from lilbee.retrieval.query import AskResult

        mock_svc.searcher.ask_raw.return_value = AskResult(
            answer="No relevant documents found.", sources=[]
        )
        result = runner.invoke(app, ["--json", "ask", "anything"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["sources"] == []
        assert "No relevant" in data["answer"]


class TestAskModelNotFound:
    """CLI should show a friendly error when the model doesn't exist."""

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_model_not_found_human(self, mock_sync, mock_svc):
        mock_svc.searcher.ask_stream.side_effect = RuntimeError("Model 'bad' not found")
        result = runner.invoke(app, ["ask", "hello"])
        assert result.exit_code == 1
        assert "not found" in result.output

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_model_not_found_json(self, mock_sync, mock_svc):
        mock_svc.searcher.ask_raw.side_effect = RuntimeError("Model 'bad' not found")
        result = runner.invoke(app, ["--json", "ask", "hello"])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "not found" in data["error"]


class TestAskProviderError:
    """ProviderError from the LLM backend must be caught, not dumped as a traceback."""

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_provider_error_human(self, mock_sync, mock_svc):
        from lilbee.providers.base import ProviderError

        mock_svc.searcher.ask_stream.side_effect = ProviderError("model 'ghost' not found")
        result = runner.invoke(app, ["ask", "hello"])
        assert result.exit_code == 1
        assert "ghost" in result.output

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_provider_error_json(self, mock_sync, mock_svc):
        from lilbee.providers.base import ProviderError

        mock_svc.searcher.ask_raw.side_effect = ProviderError("model 'ghost' not found")
        result = runner.invoke(app, ["--json", "ask", "hello"])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "ghost" in data["error"]


class TestBackendUnavailable:
    """CLI commands should show friendly errors when the backend is unreachable."""

    _ERR = RuntimeError("Connection refused")

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, side_effect=_ERR)
    def test_sync_backend_unavailable(self, mock_sync):
        result = runner.invoke(app, ["sync"])
        assert result.exit_code == 1
        assert "Connection refused" in result.output

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, side_effect=_ERR)
    def test_sync_backend_unavailable_json(self, mock_sync):
        result = runner.invoke(app, ["--json", "sync"])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "Connection refused" in data["error"]

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, side_effect=_ERR)
    def test_rebuild_backend_unavailable(self, mock_sync):
        result = runner.invoke(app, ["rebuild"])
        assert result.exit_code == 1
        assert "Connection refused" in result.output

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, side_effect=_ERR)
    def test_rebuild_backend_unavailable_json(self, mock_sync):
        result = runner.invoke(app, ["--json", "rebuild"])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "Connection refused" in data["error"]

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, side_effect=_ERR)
    def test_add_backend_unavailable(self, mock_sync, isolated_env, tmp_path):
        src = tmp_path / "source" / "test.txt"
        src.parent.mkdir()
        src.write_text("content")
        result = runner.invoke(app, ["add", str(src)])
        assert result.exit_code == 1
        assert "Connection refused" in result.output

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, side_effect=_ERR)
    def test_add_backend_unavailable_json(self, mock_sync, isolated_env, tmp_path):
        src = tmp_path / "source" / "test.txt"
        src.parent.mkdir()
        src.write_text("content")
        result = runner.invoke(app, ["--json", "add", str(src)])
        assert result.exit_code == 1
        data = json.loads(result.output.strip())
        assert "Connection refused" in data["error"]

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, side_effect=_ERR)
    def test_auto_sync_backend_unavailable(self, mock_sync):
        result = runner.invoke(app, ["ask", "hello"])
        assert result.exit_code == 1
        assert "Connection refused" in result.output


class TestEnsureChatModelWiring:
    """Verify that ask and chat call ensure_chat_model before running."""

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_calls_ensure_chat_model(self, mock_sync, mock_svc):
        mock_svc.searcher.ask_stream.return_value = _mock_stream("answer")
        with mock.patch(
            "lilbee.modelhub.models.ensure_chat_model", return_value=None
        ) as mock_ensure:
            runner.invoke(app, ["ask", "test"])
            mock_ensure.assert_called_once()

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_calls_validate_model(self, mock_sync, mock_svc):
        mock_svc.searcher.ask_stream.return_value = _mock_stream("answer")
        runner.invoke(app, ["ask", "test"])
        mock_svc.embedder.validate_model.assert_called_once()

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ask_persists_pulled_ref_when_ensure_returns_one(self, mock_sync, mock_svc):
        """ensure_chat_model returning a ref must be persisted via the settings boundary."""
        mock_svc.searcher.ask_stream.return_value = _mock_stream("answer")
        with (
            mock.patch(
                "lilbee.modelhub.models.ensure_chat_model",
                return_value="bartowski/SmolLM2-135M-Instruct-GGUF/smol.gguf",
            ),
            mock.patch("lilbee.app.settings.apply_settings_update") as mock_apply,
        ):
            runner.invoke(app, ["ask", "test"])
        mock_apply.assert_called_once_with(
            {"chat_model": "bartowski/SmolLM2-135M-Instruct-GGUF/smol.gguf"}
        )


# ---------------------------------------------------------------------------
# --ocr flag tests
# ---------------------------------------------------------------------------


class TestOcrFlags:
    """Tests for --ocr/--no-ocr and --ocr-timeout flags on sync, add, rebuild."""

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_ocr_timeout_on_sync(self, mock_sync):
        """--ocr-timeout sets cfg.ocr_timeout for sync."""
        result = runner.invoke(app, ["sync", "--ocr", "--ocr-timeout=60"])
        assert result.exit_code == 0
        assert cfg.enable_ocr is True
        assert cfg.ocr_timeout == 60.0

    def test_ocr_timeout_on_add(self, isolated_env, tmp_path, mock_svc):
        """--ocr-timeout sets cfg.ocr_timeout for add."""
        src = tmp_path / "source" / "test.txt"
        src.parent.mkdir()
        src.write_text("content")
        result = runner.invoke(app, ["add", "--ocr", "--ocr-timeout=90", str(src)])
        assert result.exit_code == 0
        assert cfg.enable_ocr is True
        assert cfg.ocr_timeout == 90.0

    def test_ocr_timeout_on_rebuild(self, mock_svc):
        """--ocr-timeout sets cfg.ocr_timeout for rebuild."""
        result = runner.invoke(app, ["rebuild", "--ocr", "--ocr-timeout=120"])
        assert result.exit_code == 0
        assert cfg.enable_ocr is True
        assert cfg.ocr_timeout == 120.0

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_no_ocr_timeout_leaves_default(self, mock_sync):
        """Without --ocr-timeout, cfg.ocr_timeout stays at default."""
        cfg.ocr_timeout = 120.0
        result = runner.invoke(app, ["sync", "--ocr"])
        assert result.exit_code == 0
        assert cfg.ocr_timeout == 120.0

    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_no_ocr_flag_disables(self, mock_sync):
        """--no-ocr sets cfg.enable_ocr to False."""
        result = runner.invoke(app, ["sync", "--no-ocr"])
        assert result.exit_code == 0
        assert cfg.enable_ocr is False


class TestLogLevel:
    """Tests for --log-level flag and LILBEE_LOG_LEVEL configuration."""

    def test_log_level_flag_debug(self):
        """--log-level=DEBUG sets root logger to DEBUG."""
        result = runner.invoke(app, ["--log-level=DEBUG", "status"])
        assert result.exit_code == 0
        assert logging.getLogger().level == logging.DEBUG

    def test_log_level_flag_info(self):
        """--log-level=INFO sets root logger to INFO."""
        result = runner.invoke(app, ["--log-level=INFO", "status"])
        assert result.exit_code == 0
        assert logging.getLogger().level == logging.INFO

    def test_log_level_env_var(self, monkeypatch):
        """LILBEE_LOG_LEVEL env var controls log level."""
        monkeypatch.setenv("LILBEE_LOG_LEVEL", "INFO")
        result = runner.invoke(app, ["status"])
        assert result.exit_code == 0
        assert logging.getLogger().level == logging.INFO

    def test_flag_overrides_env_var(self, monkeypatch):
        """--log-level overrides LILBEE_LOG_LEVEL."""
        monkeypatch.setenv("LILBEE_LOG_LEVEL", "WARNING")
        result = runner.invoke(app, ["--log-level=DEBUG", "status"])
        assert result.exit_code == 0
        assert logging.getLogger().level == logging.DEBUG

    def test_invalid_log_level_defaults_to_warning(self, monkeypatch):
        """Invalid LILBEE_LOG_LEVEL falls back to WARNING."""
        monkeypatch.setenv("LILBEE_LOG_LEVEL", "BOGUS")
        result = runner.invoke(app, ["status"])
        assert result.exit_code == 0
        assert logging.getLogger().level == logging.WARNING


class TestIngestShutdownError:
    def test_process_one_converts_shutdown_error(self):
        """RuntimeError from executor shutdown is converted to CancelledError."""
        import asyncio

        from lilbee.data.ingest import ingest_batch

        shutdown_err = RuntimeError("cannot schedule new futures after shutdown")

        async def _run():
            added = ["test.txt"]
            updated: list[str] = []
            failed: list[str] = []
            skipped: list[str] = []
            with (
                mock.patch("lilbee.data.ingest.pipeline._ingest_file", side_effect=shutdown_err),
                pytest.raises(asyncio.CancelledError),
            ):
                await ingest_batch(
                    [("test.txt", __import__("pathlib").Path("test.txt"), "text", "abc123", False)],
                    added,
                    updated,
                    failed,
                    skipped,
                    quiet=True,
                )

        asyncio.run(_run())


class TestAddWithUrls:
    """Tests for URL crawling through the add CLI command."""

    @mock.patch("lilbee.crawler.crawler_available", return_value=True)
    @mock.patch("lilbee.cli.commands.ingest_sync._crawl_urls_blocking", return_value=[])
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_add_url_triggers_crawl(self, mock_sync, mock_crawl, mock_avail):
        """Adding a URL calls the crawler instead of copying files."""
        result = runner.invoke(app, ["add", "https://example.com"])
        assert result.exit_code == 0
        mock_crawl.assert_called_once()
        args = mock_crawl.call_args
        assert args[0][0] == ["https://example.com"]

    @mock.patch("lilbee.crawler.crawler_available", return_value=True)
    @mock.patch("lilbee.cli.commands.ingest_sync._crawl_urls_blocking", return_value=[])
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_add_url_with_crawl_flag(self, mock_sync, mock_crawl, mock_avail):
        """--crawl flag is passed through to the crawler."""
        result = runner.invoke(app, ["add", "--crawl", "https://example.com"])
        assert result.exit_code == 0
        assert mock_crawl.call_args[1]["crawl"] is True

    @mock.patch("lilbee.crawler.crawler_available", return_value=True)
    @mock.patch("lilbee.cli.commands.ingest_sync._crawl_urls_blocking", return_value=[])
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_add_url_with_depth(self, mock_sync, mock_crawl, mock_avail):
        """--depth is passed through to the crawler."""
        result = runner.invoke(app, ["add", "--crawl", "--depth", "3", "https://example.com"])
        assert result.exit_code == 0
        assert mock_crawl.call_args[1]["depth"] == 3

    @mock.patch("lilbee.crawler.crawler_available", return_value=True)
    @mock.patch("lilbee.cli.commands.ingest_sync._crawl_urls_blocking", return_value=[])
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_add_url_with_max_pages(self, mock_sync, mock_crawl, mock_avail):
        """--max-pages is passed through to the crawler."""
        result = runner.invoke(app, ["add", "--crawl", "--max-pages", "10", "https://example.com"])
        assert result.exit_code == 0
        assert mock_crawl.call_args[1]["max_pages"] == 10

    @mock.patch("lilbee.crawler.crawler_available", return_value=True)
    @mock.patch("lilbee.cli.commands.ingest_sync._crawl_urls_blocking", return_value=[])
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_add_url_defaults_include_subdomains_false(self, mock_sync, mock_crawl, mock_avail):
        """default scope is the starting host only, no subdomains."""
        result = runner.invoke(app, ["add", "--crawl", "https://example.com"])
        assert result.exit_code == 0
        assert mock_crawl.call_args[1]["include_subdomains"] is False

    @mock.patch("lilbee.crawler.crawler_available", return_value=True)
    @mock.patch("lilbee.cli.commands.ingest_sync._crawl_urls_blocking", return_value=[])
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_add_url_opt_in_include_subdomains(self, mock_sync, mock_crawl, mock_avail):
        """--include-subdomains broadens scope to sibling subdomains."""
        result = runner.invoke(
            app, ["add", "--crawl", "--include-subdomains", "https://example.com"]
        )
        assert result.exit_code == 0
        assert mock_crawl.call_args[1]["include_subdomains"] is True

    @mock.patch("lilbee.crawler.crawler_available", return_value=True)
    @mock.patch("lilbee.cli.commands.ingest_sync._crawl_urls_blocking")
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_add_url_json_mode(self, mock_sync, mock_crawl, mock_avail, isolated_env):
        """URL add in JSON mode returns structured output."""
        from pathlib import Path

        mock_crawl.return_value = [Path("a.md")]
        result = runner.invoke(app, ["--json", "add", "https://example.com"])
        assert result.exit_code == 0
        data = json.loads(result.output.strip())
        assert data["command"] == "add"
        assert data["crawled"] == 1

    @mock.patch("lilbee.crawler.crawler_available", return_value=True)
    @mock.patch("lilbee.cli.commands.ingest_sync._crawl_urls_blocking")
    @mock.patch("lilbee.data.ingest.sync", new_callable=AsyncMock, return_value=_SYNC_NOOP)
    def test_add_url_json_output_is_clean(self, mock_sync, mock_crawl, mock_avail, isolated_env):
        """--json add with a URL produces clean JSON with no crawl4ai prefix text."""
        from pathlib import Path

        mock_crawl.return_value = [Path("a.md")]
        result = runner.invoke(app, ["--json", "add", "https://example.com"])
        assert result.exit_code == 0
        raw = result.output.strip()
        # Must start with '{': no [INIT]/[FETCH]/spinner text leaked to stdout
        assert raw.startswith("{"), f"stdout has non-JSON prefix: {raw[:80]}"
        data = json.loads(raw)
        assert data["command"] == "add"

    @mock.patch("lilbee.crawler.crawler_available", return_value=True)
    @mock.patch("lilbee.cli.commands.ingest_sync._crawl_urls_blocking", return_value=[])
    def test_add_mixed_urls_and_files(
        self, mock_crawl, mock_avail, isolated_env, tmp_path, mock_svc
    ):
        """Mixing URLs and file paths in one add command."""
        src = tmp_path / "source" / "doc.txt"
        src.parent.mkdir()
        src.write_text("file content")
        result = runner.invoke(app, ["add", str(src), "https://example.com"])
        assert result.exit_code == 0
        mock_crawl.assert_called_once()

    def test_add_url_without_crawler_installed(self):
        """Adding a URL when crawl4ai is not installed shows install message."""
        with mock.patch("lilbee.crawler.crawler_available", return_value=False):
            result = runner.invoke(app, ["add", "https://example.com"])
            assert result.exit_code == 1
            assert "pip install" in result.output.lower()

    def test_add_nonexistent_path_fails(self, tmp_path):
        """Adding a nonexistent file path fails with error."""
        result = runner.invoke(app, ["add", str(tmp_path / "nonexistent_crawl_test_xyz.txt")])
        assert result.exit_code != 0

    def test_add_nonexistent_path_json_fails(self, tmp_path):
        """Adding a nonexistent file path in JSON mode returns error."""
        result = runner.invoke(
            app, ["--json", "add", str(tmp_path / "nonexistent_crawl_test_xyz.txt")]
        )
        assert result.exit_code != 0


class TestIsUrl:
    def test_http(self):
        from lilbee.crawler import is_url

        assert is_url("http://example.com")

    def test_https(self):
        from lilbee.crawler import is_url

        assert is_url("https://example.com")

    def test_not_url(self):
        from lilbee.crawler import is_url

        assert not is_url("/some/file.txt")

    def test_ftp_not_url(self):
        from lilbee.crawler import is_url

        assert not is_url("ftp://example.com")


class TestPartitionInputs:
    def test_separates_urls_and_paths(self):
        from lilbee.cli.commands.ingest_sync import _partition_inputs

        paths, urls = _partition_inputs(["/some/a.txt", "https://example.com", "/some/b.txt"])
        assert len(paths) == 2
        assert urls == ["https://example.com"]

    def test_all_urls(self):
        from lilbee.cli.commands.ingest_sync import _partition_inputs

        paths, urls = _partition_inputs(["https://a.com", "http://b.com"])
        assert len(paths) == 0
        assert len(urls) == 2

    def test_all_paths(self):
        from lilbee.cli.commands.ingest_sync import _partition_inputs

        paths, urls = _partition_inputs(["/a.txt", "/b.txt"])
        assert len(paths) == 2
        assert len(urls) == 0


class TestCrawlUrlsBlocking:
    @mock.patch("lilbee.crawler.crawl_and_save", new_callable=AsyncMock)
    def test_single_url(self, mock_crawl, isolated_env):
        from pathlib import Path

        from lilbee.cli.commands.ingest_sync import _crawl_urls_blocking

        async def _fake_crawl(url, **kwargs):
            # Call the on_progress callback to cover the closure body
            from lilbee.runtime.progress import CrawlPageEvent, EventType

            cb = kwargs.get("on_progress")
            if cb:
                cb(EventType.CRAWL_PAGE, CrawlPageEvent(current=1, total=1, url=url))
            return [Path("page.md")]

        mock_crawl.side_effect = _fake_crawl
        result = _crawl_urls_blocking(
            ["https://example.com"], crawl=False, depth=None, max_pages=None
        )
        assert len(result) == 1
        mock_crawl.assert_called_once()

    @mock.patch("lilbee.crawler.crawl_and_save", new_callable=AsyncMock)
    def test_with_crawl_flag(self, mock_crawl, isolated_env):
        from lilbee.cli.commands.ingest_sync import _crawl_urls_blocking

        mock_crawl.return_value = []
        _crawl_urls_blocking(["https://example.com"], crawl=True, depth=None, max_pages=None)
        call_kwargs = mock_crawl.call_args[1]
        assert call_kwargs["depth"] == cfg.crawl_max_depth

    @mock.patch("lilbee.crawler.crawl_and_save", new_callable=AsyncMock)
    def test_with_explicit_depth(self, mock_crawl, isolated_env):
        from lilbee.cli.commands.ingest_sync import _crawl_urls_blocking

        mock_crawl.return_value = []
        _crawl_urls_blocking(["https://example.com"], crawl=True, depth=5, max_pages=20)
        call_kwargs = mock_crawl.call_args[1]
        assert call_kwargs["depth"] == 5
        assert call_kwargs["max_pages"] == 20

    @mock.patch("lilbee.crawler.crawl_and_save", new_callable=AsyncMock)
    def test_json_mode_passes_quiet(self, mock_crawl, isolated_env):
        """In JSON mode, quiet=True is passed to crawl_and_save."""
        from lilbee.cli.commands.ingest_sync import _crawl_urls_blocking

        mock_crawl.return_value = []
        cfg.json_mode = True
        _crawl_urls_blocking(["https://example.com"], crawl=False, depth=None, max_pages=None)
        call_kwargs = mock_crawl.call_args[1]
        assert call_kwargs["quiet"] is True

    @mock.patch("lilbee.cli.commands.ingest_sync._run_crawl_with_signal_cancel")
    def test_cancel_event_breaks_multi_url_loop(self, mock_run, isolated_env):
        """If the SIGINT handler sets cancel mid-run, the next URL is skipped."""
        from lilbee.cli.commands.ingest_sync import _crawl_urls_blocking

        call_log = []

        def fake_run(
            url,
            *,
            depth,
            max_pages,
            on_progress,
            cancel_event,
            crawl_and_save,
            include_subdomains=False,
        ):
            call_log.append(url)
            # Simulate SIGINT landing during the first URL's crawl:
            cancel_event.set()
            return []

        mock_run.side_effect = fake_run
        _crawl_urls_blocking(
            ["https://example.com/a", "https://example.com/b"],
            crawl=False,
            depth=None,
            max_pages=None,
        )
        # Second URL must be skipped because cancel was set during the first.
        assert call_log == ["https://example.com/a"]

    def test_sigint_handler_sets_cancel_event(self, isolated_env):
        """The signal handler installed by _run_crawl_with_signal_cancel sets the event."""
        import signal
        import threading

        from lilbee.cli.commands.ingest_sync import _run_crawl_with_signal_cancel

        cancel_event = threading.Event()

        async def fake_crawl(*args, **kwargs):
            # Capture the handler that was installed by _run_crawl_with_signal_cancel
            handler = signal.getsignal(signal.SIGINT)
            # Call it directly to simulate a SIGINT firing
            handler(signal.SIGINT, None)
            return []

        _run_crawl_with_signal_cancel(
            "https://example.com",
            depth=0,
            max_pages=None,
            on_progress=None,
            cancel_event=cancel_event,
            crawl_and_save=fake_crawl,
        )
        assert cancel_event.is_set()


class TestTopicsCommand:
    def test_not_installed_shows_error(self):
        with mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=False):
            result = runner.invoke(app, ["topics"])
            assert result.exit_code == 1
            assert "pip install" in result.output.lower()

    def test_not_installed_json_mode(self):
        with mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=False):
            result = runner.invoke(app, ["--json", "topics"])
            assert result.exit_code == 1
            output = json.loads(result.output)
            assert "error" in output

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_disabled_shows_error(self, _mock_avail):
        cfg.concept_graph = False
        result = runner.invoke(app, ["topics"])
        assert result.exit_code == 1
        assert "disabled" in result.output.lower()

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_disabled_json_mode(self, _mock_avail):
        cfg.concept_graph = False
        result = runner.invoke(app, ["--json", "topics"])
        assert result.exit_code == 1
        output = json.loads(result.output)
        assert "error" in output

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_overview_shows_communities(self, _mock_avail, mock_svc):
        from lilbee.retrieval.concepts import Community

        cfg.concept_graph = True
        mock_svc.concepts.get_graph.return_value = True
        mock_svc.concepts.top_communities.return_value = [
            Community(cluster_id=0, size=3, concepts=["python", "django", "flask"]),
        ]
        result = runner.invoke(app, ["topics"])
        assert result.exit_code == 0
        assert "python" in result.output

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_overview_json_mode(self, _mock_avail, mock_svc):
        from lilbee.retrieval.concepts import Community

        cfg.concept_graph = True
        mock_svc.concepts.get_graph.return_value = True
        mock_svc.concepts.top_communities.return_value = [
            Community(cluster_id=0, size=2, concepts=["ml", "ai"]),
        ]
        result = runner.invoke(app, ["--json", "topics"])
        assert result.exit_code == 0
        output = json.loads(result.output)
        assert output["command"] == "topics"
        assert len(output["communities"]) == 1

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_query_shows_related_concepts(self, _mock_avail, mock_svc):
        cfg.concept_graph = True
        mock_svc.concepts.get_graph.return_value = True
        mock_svc.concepts.extract_concepts.return_value = ["python"]
        mock_svc.concepts.expand_query.return_value = ["django", "flask"]
        result = runner.invoke(app, ["topics", "python"])
        assert result.exit_code == 0
        assert "django" in result.output

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_query_json_mode(self, _mock_avail, mock_svc):
        cfg.concept_graph = True
        mock_svc.concepts.get_graph.return_value = True
        mock_svc.concepts.extract_concepts.return_value = ["python"]
        mock_svc.concepts.expand_query.return_value = ["django"]
        result = runner.invoke(app, ["--json", "topics", "python"])
        assert result.exit_code == 0
        output = json.loads(result.output)
        assert "python" in output["concepts"]
        assert "django" in output["concepts"]

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_no_communities(self, _mock_avail, mock_svc):
        cfg.concept_graph = True
        mock_svc.concepts.get_graph.return_value = True
        mock_svc.concepts.top_communities.return_value = []
        result = runner.invoke(app, ["topics"])
        assert result.exit_code == 0
        assert "No concept communities" in result.output

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_query_no_concepts(self, _mock_avail, mock_svc):
        cfg.concept_graph = True
        mock_svc.concepts.get_graph.return_value = True
        mock_svc.concepts.extract_concepts.return_value = []
        mock_svc.concepts.expand_query.return_value = []
        result = runner.invoke(app, ["topics", "???"])
        assert result.exit_code == 0
        assert "No concepts found" in result.output

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_graph_none_shows_error(self, _mock_avail, mock_svc):
        cfg.concept_graph = True
        mock_svc.concepts.get_graph.return_value = False
        result = runner.invoke(app, ["topics"])
        assert result.exit_code == 1
        assert "not available" in result.output.lower()

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_graph_none_json_mode(self, _mock_avail, mock_svc):
        cfg.concept_graph = True
        mock_svc.concepts.get_graph.return_value = False
        result = runner.invoke(app, ["--json", "topics"])
        assert result.exit_code == 1
        output = json.loads(result.output)
        assert "error" in output

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_top_k_option(self, _mock_avail, mock_svc):
        cfg.concept_graph = True
        mock_svc.concepts.get_graph.return_value = True
        mock_svc.concepts.top_communities.return_value = []
        runner.invoke(app, ["topics", "--top-k", "5"])
        mock_svc.concepts.top_communities.assert_called_once_with(k=5)

    @mock.patch("lilbee.retrieval.concepts.concepts_available", return_value=True)
    def test_large_community_shows_more_count(self, _mock_avail, mock_svc):
        from lilbee.retrieval.concepts import Community

        cfg.concept_graph = True
        mock_svc.concepts.get_graph.return_value = True
        many_concepts = [f"concept_{i}" for i in range(8)]
        mock_svc.concepts.top_communities.return_value = [
            Community(cluster_id=0, size=8, concepts=many_concepts),
        ]
        result = runner.invoke(app, ["topics"])
        assert result.exit_code == 0
        assert "concept_0" in result.output
        assert "more)" in result.output


class TestWikiLint:
    def test_lint_all_no_issues(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        mock_svc.store.get_citations_for_wiki.return_value = []
        result = runner.invoke(app, ["wiki", "lint"])
        assert result.exit_code == 0
        assert "No issues found" in result.output

    def test_lint_all_with_issues(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        wiki_dir = isolated_env / "wiki" / "summaries"
        wiki_dir.mkdir(parents=True)
        (wiki_dir / "doc.md").write_text("Unmarked claim.\n")
        mock_svc.store.get_citations_for_wiki.return_value = []
        result = runner.invoke(app, ["wiki", "lint"])
        assert result.exit_code == 0
        assert "Unmarked" in result.output

    def test_lint_single_page(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        wiki_dir = isolated_env / "wiki" / "summaries"
        wiki_dir.mkdir(parents=True)
        (wiki_dir / "doc.md").write_text(
            "> Cited.[^src1]\n\n"
            "---\n"
            "<!-- citations (auto-generated from _citations table -- do not edit) -->\n"
            "[^src1]: doc.md, lines 1-5\n"
        )
        mock_svc.store.get_citations_for_wiki.return_value = []
        result = runner.invoke(app, ["wiki", "lint", "wiki/summaries/doc.md"])
        assert result.exit_code == 0

    def test_lint_json_output(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        cfg.json_mode = True
        mock_svc.store.get_citations_for_wiki.return_value = []
        result = runner.invoke(app, ["--json", "wiki", "lint"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["command"] == "wiki_lint"
        assert "total" in data


@pytest.mark.usefixtures("wiki_enabled")
class TestWikiSynthesize:
    def test_no_pages_prints_message(self, mock_svc, isolated_env, monkeypatch):
        monkeypatch.setattr("lilbee.wiki.generation.generate_synthesis_pages", lambda *a, **kw: [])
        result = runner.invoke(app, ["wiki", "synthesize"])
        assert result.exit_code == 0
        assert "No synthesis pages" in result.output

    def test_prints_generated_paths(self, mock_svc, isolated_env, monkeypatch):
        out = isolated_env / "wiki" / "synthesis" / "typing.md"
        monkeypatch.setattr(
            "lilbee.wiki.generation.generate_synthesis_pages", lambda *a, **kw: [out]
        )
        result = runner.invoke(app, ["wiki", "synthesize"])
        assert result.exit_code == 0
        assert "typing.md" in result.output

    def test_json_output(self, mock_svc, isolated_env, monkeypatch):
        cfg.json_mode = True
        out = isolated_env / "wiki" / "synthesis" / "typing.md"
        monkeypatch.setattr(
            "lilbee.wiki.generation.generate_synthesis_pages", lambda *a, **kw: [out]
        )
        result = runner.invoke(app, ["--json", "wiki", "synthesize"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["command"] == "wiki_synthesize"
        assert data["count"] == 1

    def test_wiki_disabled_prints_message(self, mock_svc, isolated_env):
        cfg.wiki = False
        result = runner.invoke(app, ["wiki", "synthesize"])
        assert result.exit_code == 0
        assert msg.CMD_WIKI_DISABLED in result.output

    def test_wiki_disabled_json_mode(self, mock_svc, isolated_env):
        cfg.wiki = False
        cfg.json_mode = True
        result = runner.invoke(app, ["--json", "wiki", "synthesize"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["error"] == msg.CMD_WIKI_DISABLED


@pytest.mark.usefixtures("wiki_enabled")
class TestWikiBuild:
    def _stub_extraction(self, monkeypatch):
        fake_extractor = MagicMock()
        fake_extractor.extract.return_value = []
        monkeypatch.setattr(
            "lilbee.wiki.entity_extractor.get_entity_extractor",
            lambda *a, **kw: fake_extractor,
        )
        return fake_extractor

    def test_no_pages_prints_message(self, mock_svc, isolated_env, monkeypatch):
        monkeypatch.setattr(
            "lilbee.wiki.run_full_build",
            lambda *a, **kw: {"paths": [], "entities": 0, "count": 0},
        )
        result = runner.invoke(app, ["wiki", "build"])
        assert result.exit_code == 0
        assert "No concept or entity pages" in result.output

    def test_prints_generated_paths(self, mock_svc, isolated_env, monkeypatch):
        out = isolated_env / "wiki" / "concepts" / "braking.md"
        monkeypatch.setattr(
            "lilbee.wiki.run_full_build",
            lambda *a, **kw: {"paths": [str(out)], "entities": 0, "count": 1},
        )
        result = runner.invoke(app, ["wiki", "build"])
        assert result.exit_code == 0
        assert "braking.md" in result.output

    def test_json_output(self, mock_svc, isolated_env, monkeypatch):
        cfg.json_mode = True
        out = isolated_env / "wiki" / "entities" / "henry-ford.md"
        monkeypatch.setattr(
            "lilbee.wiki.run_full_build",
            lambda *a, **kw: {"paths": [str(out)], "entities": 0, "count": 1},
        )
        result = runner.invoke(app, ["--json", "wiki", "build"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["command"] == "wiki_build"
        assert data["count"] == 1
        assert data["entities"] == 0

    def test_update_reruns_build(self, mock_svc, isolated_env, monkeypatch):
        """wiki update currently delegates to wiki build (see bb-he8o for smarter version)."""
        monkeypatch.setattr(
            "lilbee.wiki.run_full_build",
            lambda *a, **kw: {"paths": [], "entities": 0, "count": 0},
        )
        result = runner.invoke(app, ["wiki", "update"])
        assert result.exit_code == 0
        assert "No concept or entity pages" in result.output

    def test_collects_chunks_from_every_source(self, mock_svc, isolated_env, monkeypatch):
        """wiki_build pulls chunks for every tracked source, not just the first."""
        mock_svc.store.get_sources.return_value = [
            {"filename": "a.txt"},
            {"filename": "b.txt"},
        ]
        chunk_calls: list[str] = []

        def fake_chunks(source: str) -> list:
            chunk_calls.append(source)
            return []

        mock_svc.store.get_chunks_by_source.side_effect = fake_chunks
        self._stub_extraction(monkeypatch)
        monkeypatch.setattr("lilbee.wiki.generation.build_wiki", lambda *a, **kw: [])
        monkeypatch.setattr("lilbee.wiki.generation.update_wiki_index", lambda *a, **kw: None)
        monkeypatch.setattr("lilbee.wiki.generation.append_wiki_log", lambda *a, **kw: None)
        result = runner.invoke(app, ["wiki", "build"])
        assert result.exit_code == 0
        assert chunk_calls == ["a.txt", "b.txt"]

    def test_wiki_disabled_prints_message(self, mock_svc, isolated_env):
        cfg.wiki = False
        result = runner.invoke(app, ["wiki", "build"])
        assert result.exit_code == 0
        assert msg.CMD_WIKI_DISABLED in result.output

    def test_dry_run_skips_build_wiki_and_shows_candidates(
        self, mock_svc, isolated_env, monkeypatch
    ):
        """``--dry-run`` prints the extraction candidates and never
        invokes ``build_wiki``. The page builder is stubbed to raise so
        any call surfaces as a test failure."""
        from lilbee.wiki.entity_extractor import EntityKind, ExtractedEntity
        from lilbee.wiki.entity_extractor.base import ChunkRef

        mock_svc.store.get_sources.return_value = []
        extractor = self._stub_extraction(monkeypatch)
        extractor.extract.return_value = [
            ExtractedEntity(
                slug="chevrolet",
                kind=EntityKind.ENTITY,
                label="Chevrolet",
                type_hint="ORG",
                chunk_refs=(ChunkRef(source="a.md", chunk_index=0),),
            ),
        ]

        def build_boom(*a, **kw):
            raise AssertionError("build_wiki must not run in --dry-run")

        # Patch the impl, not the re-export: `run_full_build` resolves
        # `build_wiki` from `lilbee.wiki.generation`, so a dry-run regression that
        # accidentally fell through to the build path would only trip a
        # boom installed there.
        monkeypatch.setattr("lilbee.wiki.generation.build_wiki", build_boom)
        result = runner.invoke(app, ["wiki", "build", "--dry-run"])
        assert result.exit_code == 0
        assert "chevrolet" in result.output
        assert "dry-run" in result.output.lower()
        assert "No LLM calls were made" in result.output

    def test_dry_run_json_output(self, mock_svc, isolated_env, monkeypatch):
        from lilbee.wiki.entity_extractor import EntityKind, ExtractedEntity
        from lilbee.wiki.entity_extractor.base import ChunkRef

        cfg.json_mode = True
        mock_svc.store.get_sources.return_value = []
        extractor = self._stub_extraction(monkeypatch)
        extractor.extract.return_value = [
            ExtractedEntity(
                slug="x",
                kind=EntityKind.CONCEPT,
                label="x",
                type_hint="noun_phrase",
                chunk_refs=(
                    ChunkRef(source="a.md", chunk_index=0),
                    ChunkRef(source="b.md", chunk_index=3),
                ),
            ),
        ]

        def build_boom(*a, **kw):
            raise AssertionError("build_wiki must not run in --dry-run")

        # Patch the impl, not the re-export: `run_full_build` resolves
        # `build_wiki` from `lilbee.wiki.generation`, so a dry-run regression that
        # accidentally fell through to the build path would only trip a
        # boom installed there.
        monkeypatch.setattr("lilbee.wiki.generation.build_wiki", build_boom)
        result = runner.invoke(app, ["--json", "wiki", "build", "--dry-run"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["command"] == "wiki_build"
        assert data["dry_run"] is True
        assert data["count"] == 1
        assert data["entities"][0]["slug"] == "x"
        assert data["entities"][0]["mentions"] == 2
        assert sorted(data["entities"][0]["sources"]) == ["a.md", "b.md"]

    def test_dry_run_with_no_candidates_prints_empty_note(
        self, mock_svc, isolated_env, monkeypatch
    ):
        mock_svc.store.get_sources.return_value = []
        self._stub_extraction(monkeypatch)
        result = runner.invoke(app, ["wiki", "build", "--dry-run"])
        assert result.exit_code == 0
        assert "No candidate entities" in result.output

    def test_dry_run_collects_chunks_from_each_source(self, mock_svc, isolated_env, monkeypatch):
        """Dry-run iterates every tracked source to feed the entity extractor."""
        mock_svc.store.get_sources.return_value = [
            {"filename": "a.txt"},
            {"filename": "b.txt"},
        ]
        chunk_calls: list[str] = []

        def fake_chunks(source: str) -> list:
            chunk_calls.append(source)
            return []

        mock_svc.store.get_chunks_by_source.side_effect = fake_chunks
        self._stub_extraction(monkeypatch)
        result = runner.invoke(app, ["wiki", "build", "--dry-run"])
        assert result.exit_code == 0
        assert chunk_calls == ["a.txt", "b.txt"]


class TestWikiCitations:
    def test_citations_empty(self, mock_svc):
        mock_svc.store.get_citations_for_wiki.return_value = []
        result = runner.invoke(app, ["wiki", "citations", "wiki/summaries/doc.md"])
        assert result.exit_code == 0
        assert "No citations found" in result.output

    def test_citations_with_records(self, mock_svc):
        mock_svc.store.get_citations_for_wiki.return_value = [
            {
                "wiki_source": "wiki/summaries/doc.md",
                "wiki_chunk_index": 0,
                "citation_key": "src1",
                "claim_type": "fact",
                "source_filename": "doc.md",
                "source_hash": "abc",
                "page_start": 0,
                "page_end": 0,
                "line_start": 1,
                "line_end": 10,
                "excerpt": "Python supports typing.",
                "created_at": "2026-01-01",
            }
        ]
        result = runner.invoke(app, ["wiki", "citations", "wiki/summaries/doc.md"])
        assert result.exit_code == 0
        assert "src1" in result.output
        assert "doc.md" in result.output

    def test_citations_long_excerpt_truncated(self, mock_svc):
        long_excerpt = "A" * 80
        mock_svc.store.get_citations_for_wiki.return_value = [
            {
                "wiki_source": "wiki/summaries/doc.md",
                "wiki_chunk_index": 0,
                "citation_key": "src1",
                "claim_type": "fact",
                "source_filename": "doc.md",
                "source_hash": "abc",
                "page_start": 0,
                "page_end": 0,
                "line_start": 1,
                "line_end": 10,
                "excerpt": long_excerpt,
                "created_at": "2026-01-01",
            }
        ]
        result = runner.invoke(app, ["wiki", "citations", "wiki/summaries/doc.md"])
        assert result.exit_code == 0
        # Full 80-char excerpt should not appear: truncated by code or Rich
        assert long_excerpt not in result.output

    def test_citations_json_output(self, mock_svc):
        cfg.json_mode = True
        mock_svc.store.get_citations_for_wiki.return_value = []
        result = runner.invoke(app, ["--json", "wiki", "citations", "wiki/summaries/doc.md"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["command"] == "wiki_citations"
        assert data["total"] == 0


class TestWikiStatus:
    def test_status_no_wiki_dir(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        result = runner.invoke(app, ["wiki", "status"])
        assert result.exit_code == 0
        assert "does not exist" in result.output

    def test_status_with_pages(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        (isolated_env / "wiki" / "summaries").mkdir(parents=True)
        (isolated_env / "wiki" / "summaries" / "a.md").write_text("content")
        (isolated_env / "wiki" / "drafts").mkdir(parents=True)
        (isolated_env / "wiki" / "drafts" / "b.md").write_text("content")
        mock_svc.store.get_citations_for_wiki.return_value = []
        result = runner.invoke(app, ["wiki", "status"])
        assert result.exit_code == 0
        assert "1" in result.output  # summaries count

    def test_status_json_output(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        cfg.json_mode = True
        result = runner.invoke(app, ["--json", "wiki", "status"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert "wiki_enabled" in data
        assert data["pages"] == 0

    def test_status_json_with_pages(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        cfg.json_mode = True
        (isolated_env / "wiki" / "summaries").mkdir(parents=True)
        (isolated_env / "wiki" / "summaries" / "a.md").write_text("content")
        mock_svc.store.get_citations_for_wiki.return_value = []
        result = runner.invoke(app, ["--json", "wiki", "status"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["summaries"] == 1
        assert data["drafts"] == 0

    def test_status_all_clean(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        (isolated_env / "wiki" / "summaries").mkdir(parents=True)
        (isolated_env / "wiki" / "summaries" / "a.md").write_text(
            "> Cited.[^src1]\n\n"
            "---\n"
            "<!-- citations (auto-generated from _citations table -- do not edit) -->\n"
            "[^src1]: doc.md, lines 1-5\n"
        )
        mock_svc.store.get_citations_for_wiki.return_value = []
        result = runner.invoke(app, ["wiki", "status"])
        assert result.exit_code == 0
        assert "all clean" in result.output

    def test_status_wiki_disabled(self, mock_svc, isolated_env):
        cfg.wiki = False
        cfg.wiki_dir = "wiki"
        result = runner.invoke(app, ["wiki", "status"])
        assert result.exit_code == 0


class TestWikiPrune:
    def test_prune_no_pages(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        with mock.patch("lilbee.wiki.prune.prune_wiki") as mock_prune:
            from lilbee.wiki.prune import PruneReport

            mock_prune.return_value = PruneReport()
            result = runner.invoke(app, ["wiki", "prune"])
        assert result.exit_code == 0
        assert "No pages pruned" in result.output

    def test_prune_json_output(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        cfg.json_mode = True
        with mock.patch("lilbee.wiki.prune.prune_wiki") as mock_prune:
            from lilbee.wiki.prune import PruneReport

            mock_prune.return_value = PruneReport()
            result = runner.invoke(app, ["--json", "wiki", "prune"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["command"] == "wiki_prune"
        assert data["archived"] == 0

    def test_prune_with_records(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        with mock.patch("lilbee.wiki.prune.prune_wiki") as mock_prune:
            from lilbee.wiki.prune import PruneAction, PruneRecord, PruneReport

            report = PruneReport()
            report.records = [
                PruneRecord(
                    wiki_source="wiki/summaries/old.md",
                    action=PruneAction.ARCHIVED,
                    reason="all sources deleted",
                ),
            ]
            mock_prune.return_value = report
            result = runner.invoke(app, ["wiki", "prune"])
        assert result.exit_code == 0
        assert "old.md" in result.output


class TestWikiDraftsCli:
    """Phase B1/D: ``wiki drafts list / diff / accept / reject`` CLI surface."""

    def _seed(self, isolated_env: Path) -> Path:
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        drafts = isolated_env / "wiki" / "drafts"
        drafts.mkdir(parents=True)
        (drafts / "x.md").write_text(
            "<!-- DRIFT: 25% content changed - flagged for human review -->\n\n"
            "---\nfaithfulness_score: 0.8\n---\n\n# X\n\nnew body\n",
            encoding="utf-8",
        )
        summaries = isolated_env / "wiki" / "summaries"
        summaries.mkdir(parents=True)
        (summaries / "x.md").write_text("old body\n", encoding="utf-8")
        return isolated_env

    def test_list_no_drafts(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        result = runner.invoke(app, ["wiki", "drafts", "list"])
        assert result.exit_code == 0
        assert "No drafts pending review" in result.output

    def test_list_renders_table(self, mock_svc, isolated_env):
        self._seed(isolated_env)
        result = runner.invoke(app, ["wiki", "drafts", "list"])
        assert result.exit_code == 0
        assert "x" in result.output
        assert "25%" in result.output or "0.8" in result.output

    def test_list_json_output(self, mock_svc, isolated_env):
        self._seed(isolated_env)
        cfg.json_mode = True
        result = runner.invoke(app, ["--json", "wiki", "drafts", "list"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["command"] == "wiki_drafts_list"
        assert data["total"] == 1

    def test_diff_shows_unified_diff(self, mock_svc, isolated_env):
        self._seed(isolated_env)
        result = runner.invoke(app, ["wiki", "drafts", "diff", "x"])
        assert result.exit_code == 0
        assert "-old body" in result.output
        assert "+new body" in result.output

    def test_diff_json_output(self, mock_svc, isolated_env):
        self._seed(isolated_env)
        cfg.json_mode = True
        result = runner.invoke(app, ["--json", "wiki", "drafts", "diff", "x"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["command"] == "wiki_drafts_diff"

    def test_diff_missing_slug_exits_nonzero(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        result = runner.invoke(app, ["wiki", "drafts", "diff", "missing"])
        assert result.exit_code == 1
        assert "not found" in result.output

    def test_diff_missing_slug_json_error(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        cfg.json_mode = True
        result = runner.invoke(app, ["--json", "wiki", "drafts", "diff", "missing"])
        assert result.exit_code == 1
        data = json.loads(result.output)
        assert "error" in data

    def test_accept_moves_draft_into_published(self, mock_svc, isolated_env):
        self._seed(isolated_env)
        with mock.patch("lilbee.wiki.drafts.index_wiki_page", return_value=2):
            result = runner.invoke(app, ["wiki", "drafts", "accept", "x"])
        assert result.exit_code == 0
        assert "Accepted" in result.output
        assert not (isolated_env / "wiki" / "drafts" / "x.md").exists()

    def test_accept_json_output(self, mock_svc, isolated_env):
        self._seed(isolated_env)
        cfg.json_mode = True
        with mock.patch("lilbee.wiki.drafts.index_wiki_page", return_value=2):
            result = runner.invoke(app, ["--json", "wiki", "drafts", "accept", "x"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["command"] == "wiki_drafts_accept"
        assert data["slug"] == "x"

    def test_accept_missing_slug_exits_nonzero(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        result = runner.invoke(app, ["wiki", "drafts", "accept", "missing"])
        assert result.exit_code == 1

    def test_accept_missing_slug_json_error(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        cfg.json_mode = True
        result = runner.invoke(app, ["--json", "wiki", "drafts", "accept", "missing"])
        assert result.exit_code == 1
        data = json.loads(result.output)
        assert "error" in data

    def test_reject_deletes_draft(self, mock_svc, isolated_env):
        self._seed(isolated_env)
        result = runner.invoke(app, ["wiki", "drafts", "reject", "x"])
        assert result.exit_code == 0
        assert "Rejected" in result.output
        assert not (isolated_env / "wiki" / "drafts" / "x.md").exists()

    def test_reject_json_output(self, mock_svc, isolated_env):
        self._seed(isolated_env)
        cfg.json_mode = True
        result = runner.invoke(app, ["--json", "wiki", "drafts", "reject", "x"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["command"] == "wiki_drafts_reject"

    def test_reject_missing_slug_exits_nonzero(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        result = runner.invoke(app, ["wiki", "drafts", "reject", "missing"])
        assert result.exit_code == 1

    def test_reject_missing_slug_json_error(self, mock_svc, isolated_env):
        cfg.wiki = True
        cfg.wiki_dir = "wiki"
        cfg.json_mode = True
        result = runner.invoke(app, ["--json", "wiki", "drafts", "reject", "missing"])
        assert result.exit_code == 1
        data = json.loads(result.output)
        assert "error" in data


class TestCrawlProgressCallback:
    def test_crawl_page_event(self):
        """Crawl progress callback handles CrawlPageEvent."""
        from lilbee.runtime.progress import CrawlPageEvent

        event = CrawlPageEvent(url="https://example.com", current=3, total=10)
        # The callback in commands.py checks isinstance(data, CrawlPageEvent)
        assert event.current == 3
        assert event.total == 10

    def test_crawl_callback_wrong_type_raises(self):
        """Crawl progress callback raises TypeError for non-CrawlPageEvent."""
        # Simulate what _make_callback does
        from unittest.mock import MagicMock

        from lilbee.runtime.progress import EventType, FileStartEvent

        MagicMock()

        def on_progress(event_type, data):
            if event_type == EventType.CRAWL_PAGE and not isinstance(data, CrawlPageEvent):
                raise TypeError(f"Expected CrawlPageEvent, got {type(data).__name__}")

        from lilbee.runtime.progress import CrawlPageEvent

        bad_event = FileStartEvent(file="x", total_files=1, current_file=1)
        with pytest.raises(TypeError, match="Expected CrawlPageEvent"):
            on_progress(EventType.CRAWL_PAGE, bad_event)

    @mock.patch("lilbee.crawler.crawl_and_save", new_callable=AsyncMock)
    def test_crawl_callback_wrong_type_real_code(self, mock_crawl, isolated_env):
        """Exercise the real _make_callback with a bad event type."""
        from lilbee.cli.commands.ingest_sync import _crawl_urls_blocking
        from lilbee.runtime.progress import EventType, FileStartEvent

        async def _fake_crawl(url, **kwargs):
            cb = kwargs.get("on_progress")
            if cb:
                # Send wrong type for CRAWL_PAGE event
                cb(EventType.CRAWL_PAGE, FileStartEvent(file="x", total_files=1, current_file=1))
            return []

        mock_crawl.side_effect = _fake_crawl
        with pytest.raises(TypeError, match="Expected CrawlPageEvent"):
            _crawl_urls_blocking(["https://example.com"], crawl=False, depth=None, max_pages=None)


class TestLoginCommand:
    def test_login_already_logged_in_decline(self):
        """Login when already logged in and user declines."""
        with (
            mock.patch("huggingface_hub.login"),
            mock.patch("huggingface_hub.get_token", return_value="existing-token"),
            mock.patch("webbrowser.open"),
        ):
            result = runner.invoke(app, ["login"], input="n\n")
            assert result.exit_code == 0
            assert "Already logged in" in result.output

    def test_login_fresh(self):
        """Login with fresh token."""
        with (
            mock.patch("huggingface_hub.login") as mock_hf_login,
            mock.patch("huggingface_hub.get_token", return_value=None),
            mock.patch("webbrowser.open"),
        ):
            result = runner.invoke(app, ["login"], input="hf_test_token_123\n")
            assert result.exit_code == 0
            assert "Logged in" in result.output
            mock_hf_login.assert_called_once()

    def test_login_empty_token(self):
        """Login with empty token exits with error."""
        with (
            mock.patch("huggingface_hub.login"),
            mock.patch("huggingface_hub.get_token", return_value=None),
            mock.patch("webbrowser.open"),
        ):
            # typer.prompt with hide_input requires a non-empty value;
            # supply a whitespace-only token to trigger the "No token" error path
            result = runner.invoke(app, ["login"], input="   \n")
            assert result.exit_code == 1
            assert "No token" in result.output


class TestSyncProgressPrinter:
    def test_file_start_event(self):
        """_sync_progress_printer handles FILE_START event."""
        from lilbee.cli.sync import _sync_progress_printer
        from lilbee.runtime.progress import EventType, FileStartEvent

        con = MagicMock()
        cb = _sync_progress_printer(con)
        cb(EventType.FILE_START, FileStartEvent(file="doc.md", total_files=5, current_file=2))
        con.print.assert_called_once()
        assert "doc.md" in str(con.print.call_args)

    def test_done_event(self):
        """_sync_progress_printer handles DONE event with summary."""
        from lilbee.cli.sync import _sync_progress_printer
        from lilbee.runtime.progress import EventType, SyncDoneEvent

        con = MagicMock()
        cb = _sync_progress_printer(con)
        cb(EventType.DONE, SyncDoneEvent(added=1, updated=0, removed=0, failed=0, unchanged=0))
        con.print.assert_called_once()
        assert "Synced" in str(con.print.call_args)

    def test_file_start_wrong_type_raises(self):
        """_sync_progress_printer raises TypeError for wrong event type."""
        from lilbee.cli.sync import _sync_progress_printer
        from lilbee.runtime.progress import EventType, SyncDoneEvent

        con = MagicMock()
        cb = _sync_progress_printer(con)
        bad = SyncDoneEvent(added=0, updated=0, removed=0, failed=0, unchanged=0)
        with pytest.raises(TypeError, match="Expected FileStartEvent"):
            cb(EventType.FILE_START, bad)

    def test_done_wrong_type_raises(self):
        """_sync_progress_printer raises TypeError for wrong data type on DONE."""
        from lilbee.cli.sync import _sync_progress_printer
        from lilbee.runtime.progress import EventType, FileStartEvent

        con = MagicMock()
        cb = _sync_progress_printer(con)
        with pytest.raises(TypeError, match="Expected SyncDoneEvent"):
            cb(EventType.DONE, FileStartEvent(file="x", total_files=1, current_file=1))


class TestChatSyncCallback:
    def test_file_start_updates_status(self):
        """Background sync callback updates status on FILE_START."""
        from lilbee.cli.sync import SyncStatus, _chat_sync_callback
        from lilbee.runtime.progress import EventType, FileStartEvent

        status = SyncStatus()
        cb = _chat_sync_callback(status)
        cb(EventType.FILE_START, FileStartEvent(file="test.md", total_files=3, current_file=1))
        assert "test.md" in status.text

    def test_extract_updates_status(self):
        """Background sync callback updates status on EXTRACT."""
        from lilbee.cli.sync import SyncStatus, _chat_sync_callback
        from lilbee.runtime.progress import EventType, ExtractEvent

        status = SyncStatus()
        cb = _chat_sync_callback(status)
        cb(EventType.EXTRACT, ExtractEvent(file="scan.pdf", page=2, total_pages=5))
        assert "Vision OCR" in status.text
        assert "scan.pdf" in status.text

    def test_done_clears_status(self):
        """Background sync callback clears status on DONE."""
        from lilbee.cli.sync import SyncStatus, _chat_sync_callback
        from lilbee.runtime.progress import EventType, SyncDoneEvent

        status = SyncStatus()
        status.text = "something"
        cb = _chat_sync_callback(status)
        with mock.patch("builtins.print"):
            cb(EventType.DONE, SyncDoneEvent(added=2, updated=0, removed=0, failed=0, unchanged=0))
        assert status.text == ""

    def test_file_start_wrong_type_raises(self):
        from lilbee.cli.sync import SyncStatus, _chat_sync_callback
        from lilbee.runtime.progress import EventType, SyncDoneEvent

        status = SyncStatus()
        cb = _chat_sync_callback(status)
        bad = SyncDoneEvent(added=0, updated=0, removed=0, failed=0, unchanged=0)
        with pytest.raises(TypeError, match="Expected FileStartEvent"):
            cb(EventType.FILE_START, bad)

    def test_extract_wrong_type_raises(self):
        from lilbee.cli.sync import SyncStatus, _chat_sync_callback
        from lilbee.runtime.progress import EventType, FileStartEvent

        status = SyncStatus()
        cb = _chat_sync_callback(status)
        with pytest.raises(TypeError, match="Expected ExtractEvent"):
            cb(EventType.EXTRACT, FileStartEvent(file="x", total_files=1, current_file=1))

    def test_done_wrong_type_raises(self):
        from lilbee.cli.sync import SyncStatus, _chat_sync_callback
        from lilbee.runtime.progress import EventType, FileStartEvent

        status = SyncStatus()
        cb = _chat_sync_callback(status)
        with pytest.raises(TypeError, match="Expected SyncDoneEvent"):
            cb(EventType.DONE, FileStartEvent(file="x", total_files=1, current_file=1))


class TestTemporaryOcrConfig:
    def test_ocr_timeout_override(self):
        """temporary_ocr_config overrides ocr_timeout and restores it."""
        from lilbee.app.ingest import temporary_ocr_config

        original = cfg.ocr_timeout
        with temporary_ocr_config(ocr_timeout=99.0):
            assert cfg.ocr_timeout == 99.0
        assert cfg.ocr_timeout == original


class TestSyncResultToJson:
    def test_non_sync_result_raises(self):
        """sync_result_to_json raises TypeError for non-SyncResult input."""
        from lilbee.cli.helpers import sync_result_to_json

        with pytest.raises(TypeError, match="Expected SyncResult"):
            sync_result_to_json("not a SyncResult")


class TestSetupCrawlerCommand:
    """bb-wq8g: 'lilbee setup crawler' installs Chromium."""

    def test_noop_when_already_installed(self):
        with mock.patch("lilbee.cli.commands.setup.chromium_installed", return_value=True):
            result = runner.invoke(app, ["setup", "crawler"])
        assert result.exit_code == 0
        assert "already installed" in result.stdout.lower()

    def test_runs_bootstrap_when_missing(self):
        async def _fake_bootstrap(on_progress=None):
            return None

        with (
            mock.patch("lilbee.cli.commands.setup.chromium_installed", return_value=False),
            mock.patch("lilbee.cli.commands.setup.bootstrap_chromium", new=_fake_bootstrap),
        ):
            result = runner.invoke(app, ["setup", "crawler"])
        assert result.exit_code == 0
        assert "installed" in result.stdout.lower()

    def test_propagates_bootstrap_failure_as_exit_1(self):
        from lilbee.crawler import CrawlerBrowserError

        async def _fake_bootstrap(on_progress=None):
            raise CrawlerBrowserError("offline")

        with (
            mock.patch("lilbee.cli.commands.setup.chromium_installed", return_value=False),
            mock.patch("lilbee.cli.commands.setup.bootstrap_chromium", new=_fake_bootstrap),
        ):
            result = runner.invoke(app, ["setup", "crawler"])
        assert result.exit_code == 1

    def test_already_installed_json_mode(self):
        """--json with chromium present yields already_installed=True."""
        with mock.patch("lilbee.cli.commands.setup.chromium_installed", return_value=True):
            result = runner.invoke(app, ["--json", "setup", "crawler"])
        assert result.exit_code == 0
        assert '"already_installed": true' in result.stdout

    def test_runs_bootstrap_emits_progress_and_json_success(self):
        """--json with missing chromium emits progress via on_progress, then 'installed: true'."""
        from lilbee.runtime.progress import EventType, SetupProgressEvent

        async def _fake_bootstrap(on_progress=None):
            # Exercise the progress-echo and percent-dedup branches.
            assert on_progress is not None
            on_progress(
                EventType.SETUP_PROGRESS,
                SetupProgressEvent(
                    component="chromium",
                    downloaded_bytes=10,
                    total_bytes=100,
                    detail="...",
                ),
            )
            # Same pct again: should not re-emit.
            on_progress(
                EventType.SETUP_PROGRESS,
                SetupProgressEvent(
                    component="chromium",
                    downloaded_bytes=10,
                    total_bytes=100,
                    detail="...",
                ),
            )
            on_progress(
                EventType.SETUP_PROGRESS,
                SetupProgressEvent(
                    component="chromium",
                    downloaded_bytes=50,
                    total_bytes=100,
                    detail="...",
                ),
            )
            # Non-progress events and non-progress payloads must be ignored.
            on_progress(EventType.SETUP_DONE, object())

        with (
            mock.patch("lilbee.cli.commands.setup.chromium_installed", return_value=False),
            mock.patch("lilbee.cli.commands.setup.bootstrap_chromium", new=_fake_bootstrap),
        ):
            result = runner.invoke(app, ["--json", "setup", "crawler"])
        assert result.exit_code == 0
        assert '"installed": true' in result.stdout

    def test_runs_bootstrap_emits_progress_non_json(self):
        """Non-json mode prints 'chromium: NN%' for each new percent."""
        from lilbee.runtime.progress import EventType, SetupProgressEvent

        async def _fake_bootstrap(on_progress=None):
            assert on_progress is not None
            on_progress(
                EventType.SETUP_PROGRESS,
                SetupProgressEvent(
                    component="chromium", downloaded_bytes=10, total_bytes=100, detail="..."
                ),
            )

        with (
            mock.patch("lilbee.cli.commands.setup.chromium_installed", return_value=False),
            mock.patch("lilbee.cli.commands.setup.bootstrap_chromium", new=_fake_bootstrap),
        ):
            result = runner.invoke(app, ["setup", "crawler"])
        assert result.exit_code == 0
        # The progress line goes to stderr; the typer runner mixes stderr into stdout
        # in default mode only when mix_stderr=True, but the echo's side effect runs
        # regardless. We just confirm successful completion here.

    def test_bootstrap_failure_json_mode(self):
        """--json with a bootstrap failure yields error JSON + exit 1."""
        from lilbee.crawler import CrawlerBrowserError

        async def _fake_bootstrap(on_progress=None):
            raise CrawlerBrowserError("offline")

        with (
            mock.patch("lilbee.cli.commands.setup.chromium_installed", return_value=False),
            mock.patch("lilbee.cli.commands.setup.bootstrap_chromium", new=_fake_bootstrap),
        ):
            result = runner.invoke(app, ["--json", "setup", "crawler"])
        assert result.exit_code == 1
        assert '"error"' in result.stdout
        assert "offline" in result.stdout


class TestSelfCheck:
    """`lilbee self-check` runs both legs through ``load_llama`` so the dynamic
    ``n_ctx`` picker, flash-attention default, KV cache mapping, ``n_gpu_layers``
    resolution, and OOM retry path all run in addition to verifying the vendored
    llama-cpp-python shared libraries load.

    Leg 1: chat (``create_completion``): exercises chat-mode ``load_llama`` and
    a tiny decoder-style inference.
    Leg 2: embedding (``create_embedding``): exercises embed-mode ``load_llama``
    (with the n_ctx clamp) and a single embedding call. Catches the historical
    "Memory is not initialized" assert from llama-cpp-python <0.3.19.

    Tests stub the urllib download and ``load_llama`` so they don't hit
    HuggingFace or load a real GGUF.
    """

    @staticmethod
    def _patch_load_llama(*, chat_text: str = " 4", embed_dims: int = 768):
        """Patch ``load_llama`` to return chat-leg then embed-leg fakes."""
        chat_llm = MagicMock()
        chat_llm.create_completion.return_value = {"choices": [{"text": chat_text}]}
        embed_llm = MagicMock()
        embed_llm.create_embedding.return_value = {"data": [{"embedding": [0.1] * embed_dims}]}
        return mock.patch(
            "lilbee.providers.llama_cpp.provider.load_llama",
            side_effect=[chat_llm, embed_llm],
        )

    def test_skips_download_when_model_paths_given(self, tmp_path: Path) -> None:
        chat = tmp_path / "chat.gguf"
        chat.write_bytes(b"chat")
        emb = tmp_path / "emb.gguf"
        emb.write_bytes(b"emb")
        with (
            mock.patch(
                "lilbee.cli.commands.setup._download_self_check_model",
                side_effect=AssertionError("must not download when --model-path given"),
            ),
            self._patch_load_llama(),
        ):
            result = runner.invoke(
                app,
                [
                    "--json",
                    "self-check",
                    "--chat-model-path",
                    str(chat),
                    "--embed-model-path",
                    str(emb),
                ],
            )
        assert result.exit_code == 0, result.output
        payload = json.loads(result.stdout.strip().splitlines()[-1])
        assert payload["ok"] is True
        assert payload["chat_response"] == " 4"
        assert payload["chat_model"] == str(chat)
        assert payload["embedding_dims"] == 768
        assert set(payload["provider"]) == {
            "num_ctx",
            "num_ctx_max",
            "chat_n_ctx_target",
            "flash_attention",
            "kv_cache_type",
            "n_gpu_layers",
            "main_gpu",
            "gpu_devices",
        }

    def test_downloads_when_paths_missing(self, tmp_path: Path) -> None:
        chat = tmp_path / "chat.gguf"
        chat.write_bytes(b"chat")
        emb = tmp_path / "emb.gguf"
        emb.write_bytes(b"emb")
        with (
            mock.patch(
                "lilbee.cli.commands.setup._download_self_check_model",
                side_effect=[chat, emb],
            ),
            self._patch_load_llama(),
        ):
            result = runner.invoke(app, ["--json", "self-check"])
        assert result.exit_code == 0, result.output

    def test_skip_embedding_short_circuits(self, tmp_path: Path) -> None:
        """--skip-embedding must not download or load the embedding model."""
        chat = tmp_path / "chat.gguf"
        chat.write_bytes(b"chat")
        download = mock.Mock(return_value=chat)
        with (
            mock.patch("lilbee.cli.commands.setup._download_self_check_model", download),
            self._patch_load_llama(),
        ):
            result = runner.invoke(app, ["--json", "self-check", "--skip-embedding"])
        assert result.exit_code == 0, result.output
        # Only the chat download fires; embed leg is skipped.
        assert download.call_count == 1
        payload = json.loads(result.stdout.strip().splitlines()[-1])
        assert payload["ok"] is True
        assert "embedding_dims" not in payload

    def test_self_check_invokes_load_llama_for_each_leg(self, tmp_path: Path) -> None:
        """Both legs must route through load_llama with the right LoaderMode.

        Pins the user-visible promise: self-check exercises the lilbee provider
        stack (dynamic ctx + FA + KV cache + GPU layers + OOM retry), not just
        a hand-rolled ``llama_cpp.Llama(...)`` call.
        """
        from lilbee.providers.model_cache import LoaderMode

        chat = tmp_path / "chat.gguf"
        chat.write_bytes(b"chat")
        emb = tmp_path / "emb.gguf"
        emb.write_bytes(b"emb")
        chat_llm = MagicMock()
        chat_llm.create_completion.return_value = {"choices": [{"text": " ok"}]}
        embed_llm = MagicMock()
        embed_llm.create_embedding.return_value = {"data": [{"embedding": [0.0] * 4}]}
        with mock.patch(
            "lilbee.providers.llama_cpp.provider.load_llama",
            side_effect=[chat_llm, embed_llm],
        ) as load_llama:
            result = runner.invoke(
                app,
                [
                    "--json",
                    "self-check",
                    "--chat-model-path",
                    str(chat),
                    "--embed-model-path",
                    str(emb),
                ],
            )
        assert result.exit_code == 0, result.output
        assert load_llama.call_count == 2
        assert load_llama.call_args_list[0] == mock.call(chat, mode=LoaderMode.CHAT)
        assert load_llama.call_args_list[1] == mock.call(emb, mode=LoaderMode.EMBED)

    def test_chat_download_failure_emits_json_error(self) -> None:
        with mock.patch(
            "lilbee.cli.commands.setup._download_self_check_model",
            side_effect=RuntimeError("network is down"),
        ):
            result = runner.invoke(app, ["--json", "self-check"])
        assert result.exit_code == 1
        payload = json.loads(result.stdout.strip().splitlines()[-1])
        assert payload["ok"] is False
        assert "network is down" in payload["error"]

    def test_chat_llama_load_failure_emits_json_error(self, tmp_path: Path) -> None:
        chat = tmp_path / "chat.gguf"
        chat.write_bytes(b"chat")
        with mock.patch(
            "lilbee.providers.llama_cpp.provider.load_llama",
            side_effect=RuntimeError("Shared library not found"),
        ):
            result = runner.invoke(
                app,
                [
                    "--json",
                    "self-check",
                    "--chat-model-path",
                    str(chat),
                    "--skip-embedding",
                ],
            )
        assert result.exit_code == 1
        payload = json.loads(result.stdout.strip().splitlines()[-1])
        assert payload["ok"] is False
        assert "Shared library not found" in payload["error"]

    def test_empty_chat_response_emits_json_error(self, tmp_path: Path) -> None:
        chat = tmp_path / "chat.gguf"
        chat.write_bytes(b"chat")
        with self._patch_load_llama(chat_text="   "):
            result = runner.invoke(
                app,
                [
                    "--json",
                    "self-check",
                    "--chat-model-path",
                    str(chat),
                    "--skip-embedding",
                ],
            )
        assert result.exit_code == 1
        payload = json.loads(result.stdout.strip().splitlines()[-1])
        assert payload == {"ok": False, "error": "empty inference response"}

    def test_empty_chat_response_human_mode(self, tmp_path: Path) -> None:
        chat = tmp_path / "chat.gguf"
        chat.write_bytes(b"chat")
        with self._patch_load_llama(chat_text="   "):
            result = runner.invoke(
                app,
                [
                    "self-check",
                    "--chat-model-path",
                    str(chat),
                    "--skip-embedding",
                ],
            )
        assert result.exit_code == 1
        assert "SELF-CHECK FAILED" in result.output
        assert "empty inference response" in result.output

    def test_embedding_load_failure_emits_json_error(self, tmp_path: Path) -> None:
        """The Memory-is-not-initialized class of bug: embed-leg load_llama raises."""
        chat = tmp_path / "chat.gguf"
        chat.write_bytes(b"chat")
        emb = tmp_path / "emb.gguf"
        emb.write_bytes(b"emb")
        chat_llm = MagicMock()
        chat_llm.create_completion.return_value = {"choices": [{"text": " 4"}]}
        with mock.patch(
            "lilbee.providers.llama_cpp.provider.load_llama",
            side_effect=[chat_llm, AssertionError("Memory is not initialized")],
        ):
            result = runner.invoke(
                app,
                [
                    "--json",
                    "self-check",
                    "--chat-model-path",
                    str(chat),
                    "--embed-model-path",
                    str(emb),
                ],
            )
        assert result.exit_code == 1
        payload = json.loads(result.stdout.strip().splitlines()[-1])
        assert payload["ok"] is False
        assert "Memory is not initialized" in payload["error"]

    def test_empty_embedding_emits_json_error(self, tmp_path: Path) -> None:
        chat = tmp_path / "chat.gguf"
        chat.write_bytes(b"chat")
        emb = tmp_path / "emb.gguf"
        emb.write_bytes(b"emb")
        chat_llm = MagicMock()
        chat_llm.create_completion.return_value = {"choices": [{"text": " 4"}]}
        embed_llm = MagicMock()
        embed_llm.create_embedding.return_value = {"data": [{"embedding": []}]}
        with mock.patch(
            "lilbee.providers.llama_cpp.provider.load_llama",
            side_effect=[chat_llm, embed_llm],
        ):
            result = runner.invoke(
                app,
                [
                    "--json",
                    "self-check",
                    "--chat-model-path",
                    str(chat),
                    "--embed-model-path",
                    str(emb),
                ],
            )
        assert result.exit_code == 1
        payload = json.loads(result.stdout.strip().splitlines()[-1])
        assert payload == {"ok": False, "error": "empty embedding vector"}

    def test_human_mode_on_success_with_embedding(self, tmp_path: Path) -> None:
        """Non-JSON mode prints chat response, embedding dims, provider snapshot, and PASSED."""
        chat = tmp_path / "chat.gguf"
        chat.write_bytes(b"chat")
        emb = tmp_path / "emb.gguf"
        emb.write_bytes(b"emb")
        with self._patch_load_llama(chat_text=" hello", embed_dims=384):
            result = runner.invoke(
                app,
                [
                    "self-check",
                    "--chat-model-path",
                    str(chat),
                    "--embed-model-path",
                    str(emb),
                ],
            )
        assert result.exit_code == 0, result.output
        assert "SELF-CHECK PASSED" in result.output
        assert "hello" in result.output
        assert "384" in result.output
        # The provider readout is the user-visible signal that the new
        # cfg-driven knobs were resolved this run.
        assert "Provider:" in result.output
        assert "kv_cache_type=" in result.output
        assert "flash_attention=" in result.output

    def test_human_mode_on_failure(self, tmp_path: Path) -> None:
        chat = tmp_path / "chat.gguf"
        chat.write_bytes(b"chat")
        with mock.patch(
            "lilbee.providers.llama_cpp.provider.load_llama",
            side_effect=OSError("boom"),
        ):
            result = runner.invoke(
                app,
                [
                    "self-check",
                    "--chat-model-path",
                    str(chat),
                    "--skip-embedding",
                ],
            )
        assert result.exit_code == 1
        assert "SELF-CHECK FAILED" in result.output


class TestSelfCheckExtras:
    """`lilbee self-check-extras` probes the optional extras for the frozen-binary smoke test."""

    @staticmethod
    def _import_module_stub(missing: set[str]):
        """Return a side_effect that raises ImportError for names in *missing*."""

        def _stub(name: str):
            if name in missing:
                raise ImportError(f"No module named {name!r}")
            return MagicMock(__name__=name)

        return _stub

    def test_all_extras_present_json(self) -> None:
        with mock.patch(
            "lilbee.cli.commands.setup.importlib.import_module",
            side_effect=self._import_module_stub(missing=set()),
        ):
            result = runner.invoke(app, ["--json", "self-check-extras"])
        assert result.exit_code == 0, result.output
        payload = json.loads(result.stdout.strip().splitlines()[-1])
        assert payload == {
            "ok": True,
            "litellm": True,
            "crawl4ai": True,
            "spacy": True,
            "graspologic_native": True,
        }

    def test_one_extra_missing_exits_nonzero_json(self) -> None:
        with mock.patch(
            "lilbee.cli.commands.setup.importlib.import_module",
            side_effect=self._import_module_stub(missing={"crawl4ai"}),
        ):
            result = runner.invoke(app, ["--json", "self-check-extras"])
        assert result.exit_code == 1, result.output
        payload = json.loads(result.stdout.strip().splitlines()[-1])
        assert payload["ok"] is False
        assert payload["litellm"] is True
        assert payload["crawl4ai"] is False
        assert "crawl4ai_error" in payload
        assert payload["spacy"] is True
        assert payload["graspologic_native"] is True

    def test_one_extra_missing_human_mode_reports_failure(self) -> None:
        with mock.patch(
            "lilbee.cli.commands.setup.importlib.import_module",
            side_effect=self._import_module_stub(missing={"spacy"}),
        ):
            result = runner.invoke(app, ["self-check-extras"])
        assert result.exit_code == 1, result.output
        assert "spacy" in result.output
        assert "MISSING" in result.output


class TestDownloadSelfCheckModel:
    """`_download_self_check_model` retries URLError up to 3 times."""

    def test_successful_download_returns_path(self, tmp_path: Path) -> None:
        from lilbee.cli.commands import setup as cmds

        payload = b"gguf-bytes"

        class _Resp:
            def __enter__(self):
                return self

            def __exit__(self, *a):
                return False

            def read(self):
                return payload

        with (
            mock.patch("tempfile.mkdtemp", return_value=str(tmp_path)),
            mock.patch("urllib.request.urlopen", return_value=_Resp()),
        ):
            path = cmds._download_self_check_model("repo/x", "tiny.gguf")

        assert path == tmp_path / "tiny.gguf"
        assert path.read_bytes() == payload

    def test_retries_then_raises_after_three_attempts(self, tmp_path: Path) -> None:
        import urllib.error

        from lilbee.cli.commands import setup as cmds

        err = urllib.error.URLError("dns failed")
        with (
            mock.patch("tempfile.mkdtemp", return_value=str(tmp_path)),
            mock.patch("urllib.request.urlopen", side_effect=err) as opened,
            pytest.raises(RuntimeError, match="3 attempts"),
        ):
            cmds._download_self_check_model("repo/x", "tiny.gguf")

        assert opened.call_count == 3

    def test_retry_then_succeed(self, tmp_path: Path) -> None:
        import urllib.error

        from lilbee.cli.commands import setup as cmds

        class _Resp:
            def __enter__(self):
                return self

            def __exit__(self, *a):
                return False

            def read(self):
                return b"ok"

        urlopen = mock.Mock(
            side_effect=[urllib.error.URLError("flaky"), _Resp()],
        )
        with (
            mock.patch("tempfile.mkdtemp", return_value=str(tmp_path)),
            mock.patch("urllib.request.urlopen", urlopen),
        ):
            path = cmds._download_self_check_model("repo/x", "tiny.gguf")

        assert urlopen.call_count == 2
        assert path.read_bytes() == b"ok"


class TestCrawlDefaultCapNotice:
    """A bare --crawl that fills the protective default tells the user how to go unlimited."""

    def test_prints_notice_when_default_filled(self, monkeypatch, capsys) -> None:
        from pathlib import Path

        from lilbee.cli.commands.ingest_sync import _crawl_urls_blocking
        from lilbee.core.config import cfg
        from lilbee.runtime.progress import CrawlDoneEvent, EventType

        cfg.crawl_max_pages = None
        cfg.crawl_safety_max_pages = 5

        async def fake_crawl_and_save(url, *, on_progress, **kwargs):
            on_progress(EventType.CRAWL_DONE, CrawlDoneEvent(pages_crawled=5, files_written=5))
            return [Path("p0.md")]

        monkeypatch.setattr("lilbee.crawler.crawl_and_save", fake_crawl_and_save)
        _crawl_urls_blocking(["https://example.com"], crawl=True, depth=None, max_pages=None)
        out = capsys.readouterr().err
        assert "--max-pages 0" in out and "5-page" in out

    def test_no_notice_when_under_default(self, monkeypatch, capsys) -> None:
        from pathlib import Path

        from lilbee.cli.commands.ingest_sync import _crawl_urls_blocking
        from lilbee.core.config import cfg
        from lilbee.runtime.progress import CrawlDoneEvent, EventType

        cfg.crawl_max_pages = None
        cfg.crawl_safety_max_pages = 5

        async def fake_crawl_and_save(url, *, on_progress, **kwargs):
            on_progress(EventType.CRAWL_DONE, CrawlDoneEvent(pages_crawled=2, files_written=2))
            return [Path("p0.md")]

        monkeypatch.setattr("lilbee.crawler.crawl_and_save", fake_crawl_and_save)
        _crawl_urls_blocking(["https://example.com"], crawl=True, depth=None, max_pages=None)
        assert "--max-pages 0" not in capsys.readouterr().err

    def test_no_notice_when_explicit_max_pages(self, monkeypatch, capsys) -> None:
        from pathlib import Path

        from lilbee.cli.commands.ingest_sync import _crawl_urls_blocking
        from lilbee.core.config import cfg
        from lilbee.runtime.progress import CrawlDoneEvent, EventType

        cfg.crawl_safety_max_pages = 5

        async def fake_crawl_and_save(url, *, on_progress, **kwargs):
            on_progress(EventType.CRAWL_DONE, CrawlDoneEvent(pages_crawled=9, files_written=9))
            return [Path("p0.md")]

        monkeypatch.setattr("lilbee.crawler.crawl_and_save", fake_crawl_and_save)
        _crawl_urls_blocking(["https://example.com"], crawl=True, depth=None, max_pages=9)
        assert "--max-pages 0" not in capsys.readouterr().err
