"""Regression tests for the `mm init` wizard (cli/init_cmd.py).

These tests pin down the shape of the `.mcp.json` generated by the wizard and
guard against a historical bug where MEMTOMEM_INDEXING__MEMORY_DIRS was written
as a plain string — which crashes the MCP server at startup because
pydantic-settings parses list-typed env vars as JSON arrays.
"""

from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path
from typing import Literal

import pytest

from memtomem.cli.init_cmd import MmBinaryOrigin, RuntimeProfile, _write_mcp_json
from memtomem.config import Mem2MemConfig

from .helpers import set_home


def test_write_mcp_json_omits_env_when_empty(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """When no env overrides are supplied, the generated .mcp.json must not
    include an empty or invalid `env` block. Settings come from
    ~/.memtomem/config.json at runtime instead."""
    monkeypatch.chdir(tmp_path)
    _write_mcp_json("uvx", ["--from", "memtomem", "memtomem-server"], {})

    data = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8"))
    server = data["mcpServers"]["memtomem"]
    assert server["command"] == "uvx"
    assert server["args"] == ["--from", "memtomem", "memtomem-server"]
    assert "env" not in server, (
        "wizard must not write an empty/invalid env block — regression for "
        "the MEMTOMEM_INDEXING__MEMORY_DIRS plain-string bug"
    )


def test_write_mcp_json_preserves_valid_env(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """If a caller does pass env overrides, they round-trip unchanged."""
    monkeypatch.chdir(tmp_path)
    env = {"MEMTOMEM_TOOL_MODE": "standard"}
    _write_mcp_json("uvx", ["--from", "memtomem", "memtomem-server"], env)

    data = json.loads((tmp_path / ".mcp.json").read_text(encoding="utf-8"))
    assert data["mcpServers"]["memtomem"]["env"] == env


def _make_test_profile(
    tmp_path: Path | None = None,
    *,
    kind: Literal["source", "project", "pypi"] = "pypi",
    mm_binary_origin: MmBinaryOrigin = "unknown",
    runtime_matches_workspace: bool | None = None,
) -> RuntimeProfile:
    """Build a :class:`RuntimeProfile` for tests without invoking
    :func:`_runtime_profile`.

    Replaces the pre-#368 pattern of setting
    ``state["source_install"] = True`` + ``state["source_dir"] = str(tmp_path)``
    — those legacy keys no longer exist after Phase 3 cleanup so tests that
    want a non-pypi profile seed ``state["_profile"]`` directly via this
    helper. Workspace venv detection mirrors :func:`_runtime_profile`:
    ``<cwd_install_dir>/.venv`` if it exists on disk.

    ``tmp_path`` is only meaningful when ``kind`` is ``"source"`` or
    ``"project"`` (it becomes ``cwd_install_dir``). For ``kind="pypi"`` it
    is ignored — callers may pass ``None`` or omit it.

    ``runtime_matches_workspace`` defaults to ``None`` which auto-computes
    via ``Path(sys.executable).is_relative_to(workspace_venv_path)`` — the
    same rule :func:`_runtime_profile` uses. Tests that monkeypatch
    ``sys.executable`` to point inside ``<tmp_path>/.venv`` then get ``True``
    automatically; explicit ``True``/``False`` force the flag regardless."""
    cwd_install_dir = tmp_path if kind in ("source", "project") else None
    venv = (cwd_install_dir / ".venv") if cwd_install_dir else None
    workspace_venv_path = venv if (venv and venv.exists()) else None
    runtime_interpreter = Path(sys.executable)

    matches: bool
    if runtime_matches_workspace is not None:
        matches = runtime_matches_workspace
    elif workspace_venv_path is not None:
        try:
            matches = runtime_interpreter.is_relative_to(workspace_venv_path)
        except (OSError, ValueError):
            matches = False
    else:
        matches = False

    return RuntimeProfile(
        cwd_install_type=kind,
        cwd_install_dir=cwd_install_dir,
        runtime_interpreter=runtime_interpreter,
        workspace_venv_path=workspace_venv_path,
        mm_binary_origin=mm_binary_origin,
        runtime_matches_workspace=matches,
    )


def _make_init_state(tmp_path: Path) -> dict:
    """Return a minimal wizard state dict pointing at *tmp_path*."""
    return {
        "provider": "none",
        "model": "",
        "dimension": 0,
        "db_path": str(tmp_path / "memtomem.db"),
        "memory_dir": str(tmp_path / "memories"),
        "enable_auto_ns": False,
        "default_ns": "default",
        "top_k": 10,
        "tokenizer": "unicode61",
        "decay_enabled": False,
        "rerank_enabled": False,
        # Non-config keys required by _write_config_and_summary
        "mcp_choice": 3,  # skip MCP setup
        "settings_hooks": False,
        # #368: legacy source_install / project_install / source_dir /
        # project_dir keys have been dropped — seed a default pypi profile
        # here so production code reads install-context via profile only.
        # Tests that need source/project classification overwrite
        # state["_profile"] via _make_test_profile(tmp_path, kind=...).
        "_profile": _make_test_profile(),  # default: pypi / unknown
    }


class TestInitConfigMerge:
    """mm init must preserve non-init fields on re-run."""

    def test_reinit_preserves_existing_mutable_fields(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Post-init config changes (e.g. mmr.lambda_param) must survive re-init."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        # Redirect ~/.memtomem to tmp_path/.memtomem
        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()

        # First init
        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path)

        # User modifies config post-init (simulate mm config set)
        config_path = config_dir / "config.json"
        data = json.loads(config_path.read_text(encoding="utf-8"))
        data["mmr"] = {"enabled": True, "lambda_param": 0.3}
        data["search"]["bm25_candidates"] = 200
        config_path.write_text(json.dumps(data, indent=2), encoding="utf-8")

        # Re-run init (change provider)
        state["provider"] = "onnx"
        state["model"] = "all-MiniLM-L6-v2"
        state["dimension"] = 384
        _write_config_and_summary(state, tmp_path)

        result = json.loads(config_path.read_text(encoding="utf-8"))

        # Init fields updated
        assert result["embedding"]["provider"] == "onnx"
        assert result["embedding"]["dimension"] == 384

        # Non-init fields preserved
        assert result["mmr"]["enabled"] is True
        assert result["mmr"]["lambda_param"] == 0.3
        assert result["search"]["bm25_candidates"] == 200

    def test_first_init_works_without_existing_config(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """First init (no config.json) must work exactly as before."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()

        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path)

        config_path = config_dir / "config.json"
        data = json.loads(config_path.read_text(encoding="utf-8"))
        assert data["embedding"]["provider"] == "none"
        assert data["search"]["default_top_k"] == 10


class TestStartupBackfillOptIn:
    """The wizard offers an opt-in toggle for
    ``indexing.startup_backfill`` after a successful inline seed —
    flipping the flag on every restart is a separate decision from the
    one-shot wizard seed (PR #295 lesson, see ``IndexingConfig``).
    """

    def test_config_omits_flag_when_state_unset(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Default path — no opt-in, no key in ``config.json``. Keeps
        the ``False`` default in ``IndexingConfig`` and avoids polluting
        the config file with redundant defaults."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        (tmp_path / ".memtomem").mkdir()

        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path)

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        assert "startup_backfill" not in data["indexing"]

    def test_config_emits_flag_when_state_true(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """User opted in via ``_maybe_offer_startup_backfill`` — config
        must record ``indexing.startup_backfill = true`` so the next
        ``mm web`` / ``mm server`` startup honours it."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        (tmp_path / ".memtomem").mkdir()

        state = _make_init_state(tmp_path)
        state["startup_backfill"] = True
        _write_config_and_summary(state, tmp_path)

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        assert data["indexing"]["startup_backfill"] is True

    def test_offer_sets_state_when_user_confirms(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Yes at the prompt → ``state["startup_backfill"] = True``."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd.click, "confirm", lambda *a, **kw: True)

        state: dict = {}
        init_cmd._maybe_offer_startup_backfill(state)
        assert state["startup_backfill"] is True

    def test_offer_unchanged_when_user_declines(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """No at the prompt → state unchanged, key not added. Same
        default-skip discipline as the seed prompt."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd.click, "confirm", lambda *a, **kw: False)

        state: dict = {}
        init_cmd._maybe_offer_startup_backfill(state)
        assert "startup_backfill" not in state

    def test_offer_skipped_when_non_tty(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Non-TTY (CI, piped ``mm init -y``) → silent skip without
        calling confirm. Mirrors the seed prompt's isatty gate so
        non-interactive runs stay deterministic."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: False)

        def _explode(*_a: object, **_kw: object) -> bool:
            raise AssertionError("confirm must not be called when stdin is not a TTY")

        monkeypatch.setattr(init_cmd.click, "confirm", _explode)

        state: dict = {}
        init_cmd._maybe_offer_startup_backfill(state)
        assert "startup_backfill" not in state


class TestRerankerStep:
    """`_step_reranker` writes an optional `rerank` section into config.json."""

    def test_rerank_disabled_writes_no_section(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """When the user skips the reranker step, config.json must not gain a
        `rerank` section (so defaults remain in effect and re-init doesn't
        resurrect a stale enabled flag)."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path)

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        assert "rerank" not in data

    def test_rerank_enabled_writes_multilingual_model(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Enabling reranker with multilingual model writes provider=fastembed
        and the jina multilingual model ID."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        state = _make_init_state(tmp_path)
        state["rerank_enabled"] = True
        state["rerank_model"] = "jinaai/jina-reranker-v2-base-multilingual"
        _write_config_and_summary(state, tmp_path)

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        assert data["rerank"] == {
            "enabled": True,
            "provider": "fastembed",
            "model": "jinaai/jina-reranker-v2-base-multilingual",
        }

    def test_rerank_disabled_preserves_existing_rerank_fields(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Re-running init with reranker disabled must not clobber a rerank
        section the user added manually (e.g. custom top_k)."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        config_path = config_dir / "config.json"
        config_path.write_text(
            json.dumps({"rerank": {"enabled": True, "top_k": 50}}),
            encoding="utf-8",
        )

        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path)

        data = json.loads(config_path.read_text(encoding="utf-8"))
        assert data["rerank"]["enabled"] is True
        assert data["rerank"]["top_k"] == 50


def test_write_config_and_summary_without_base_dir_falls_back_to_home(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """``_write_config_and_summary`` must be callable without *base_dir*.

    The interactive wizard's final step (``init_cmd.py:604``) invokes this
    helper with no ``base_dir``, relying on the ``Path.home()`` fallback.
    A previous refactor dropped the parameter default, making the call
    crash with ``TypeError: missing 1 required positional argument`` —
    this test pins the no-arg call as part of the contract.
    """
    from memtomem.cli.init_cmd import _write_config_and_summary

    set_home(monkeypatch, tmp_path)

    state = _make_init_state(tmp_path)
    _write_config_and_summary(state)

    assert (tmp_path / ".memtomem" / "config.json").exists()


class TestMissingExtrasWarning:
    """`_write_config_and_summary` must surface missing-extras before Next Steps.

    Preset paths (minimal/english/korean) skip ``_step_embedding`` which was
    the only inline fastembed check; without a centralized warning, a Korean
    preset user with a base (``[all]``-less) install hits
    ``Embedding failed ... fastembed is required`` silently during index. The
    summary also needs a ``[web]``-missing warning regardless of install
    type, since source-install users can still invoke the global ``mm web``
    binary (uv tool path) whose interpreter may lack ``[web]``.
    """

    def test_collect_missing_extras_none_when_provider_none(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """provider=none + all extras present → no warnings."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd, "_missing_web_deps", lambda: None, raising=False)
        # Importlib.find_spec goes through the real import machinery; keep it
        # unmocked here so fastembed presence on dev machines is the truth.
        state = {"provider": "none", "rerank_enabled": False}
        # onnx bucket only fires when provider=onnx OR rerank; none+none = []
        assert init_cmd._collect_missing_extras(state) == []

    def test_collect_missing_extras_onnx_when_fastembed_absent(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """provider=onnx + fastembed not importable → ['onnx']."""
        from memtomem.cli import init_cmd

        def fake_find_spec(name: str) -> object | None:
            return None if name == "fastembed" else object()

        monkeypatch.setattr("importlib.util.find_spec", fake_find_spec)
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)
        state = {"provider": "onnx", "rerank_enabled": False}
        assert init_cmd._collect_missing_extras(state) == ["onnx"]

    def test_collect_missing_extras_onnx_when_rerank_needs_fastembed(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Reranker enabled shares fastembed with onnx embedding — absent
        fastembed still yields ['onnx'] even if provider=none."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "fastembed" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)
        state = {
            "provider": "none",
            "rerank_enabled": True,
            "rerank_model": "jinaai/jina-reranker-v2-base-multilingual",
        }
        assert init_cmd._collect_missing_extras(state) == ["onnx"]

    def test_collect_missing_extras_web_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """provider=none + fastapi absent → ['web']."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: "fastapi", raising=False)
        state = {"provider": "none", "rerank_enabled": False}
        assert init_cmd._collect_missing_extras(state) == ["web"]

    def test_collect_missing_extras_both(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """onnx + web both missing → ['onnx', 'web'] (ordered by failure
        sequence: index before web)."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "fastembed" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: "fastapi", raising=False)
        state = {"provider": "onnx", "rerank_enabled": False}
        assert init_cmd._collect_missing_extras(state) == ["onnx", "web"]

    def test_extra_install_hint_single_uses_narrow_extra(self) -> None:
        """One missing extra → narrow hint. Default (no state / PyPI install)
        keeps the tool-install reinstall path."""
        from memtomem.cli.init_cmd import _extra_install_hint

        assert _extra_install_hint(["onnx"]) == 'uv tool install --reinstall "memtomem[onnx]"'
        assert _extra_install_hint(["web"]) == 'uv tool install --reinstall "memtomem[web]"'

    def test_extra_install_hint_multiple_collapses_to_all(self) -> None:
        """Two+ missing → collapse to ``[all]`` (single public extra covering
        both, simpler than ``[onnx,web]`` listing)."""
        from memtomem.cli.init_cmd import _extra_install_hint

        assert _extra_install_hint(["onnx", "web"]) == (
            'uv tool install --reinstall "memtomem[all]"'
        )

    def test_extra_install_hint_source_install_uses_uv_sync(self) -> None:
        """Source-install state → ``uv sync --extra <name>`` (single) and
        ``--extra all`` (multiple). Issue #360 Phase 1: workspace ``.venv``
        is what ``uv run mm`` uses, so ``uv sync`` is the right install."""
        from memtomem.cli.init_cmd import _extra_install_hint

        state = {"_profile": _make_test_profile(kind="source")}
        assert _extra_install_hint(["onnx"], state) == "uv sync --extra onnx"
        assert _extra_install_hint(["web"], state) == "uv sync --extra web"
        assert _extra_install_hint(["onnx", "web"], state) == "uv sync --extra all"

    def test_extra_install_hint_project_install_uses_uv_sync(self) -> None:
        """Project-install (``uv add memtomem`` in a non-source project)
        also resolves to the workspace ``uv sync`` hint."""
        from memtomem.cli.init_cmd import _extra_install_hint

        state = {"_profile": _make_test_profile(kind="project")}
        assert _extra_install_hint(["onnx"], state) == "uv sync --extra onnx"
        assert _extra_install_hint(["onnx", "web"], state) == "uv sync --extra all"

    def test_emit_missing_extras_warning_silent_when_empty(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Empty list → no output (common path must not add noise)."""
        from memtomem.cli.init_cmd import _emit_missing_extras_warning

        _emit_missing_extras_warning([], {})
        assert capsys.readouterr().out == ""

    def test_emit_missing_extras_warning_names_extras_and_hint(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Warning lists the extras, which commands will fail, and a single
        install hint (the fix for users seeing 'fastembed required' silently
        during index)."""
        from click import unstyle

        from memtomem.cli.init_cmd import _emit_missing_extras_warning

        _emit_missing_extras_warning(["onnx", "web"], {})
        out = unstyle(capsys.readouterr().out)
        assert "Missing extras: onnx, web" in out
        assert "mm index" in out
        assert "mm web" in out
        assert "memtomem[all]" in out

    def test_summary_surfaces_onnx_warning_on_preset_path(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """End-to-end: Korean-preset-style state (provider=onnx) + fastembed
        absent must produce a warning in the printed summary. Regression for
        the preset paths skipping ``_step_embedding``'s inline check.
        Default state has ``source_install=False`` → PyPI hint preserved.

        Phase 2 (#362): the summary now routes missing extras through
        ``_install_extras`` first; with the helper stubbed to ``False``
        (simulating user decline / subprocess failure / non-TTY) the
        Phase 1 warning path must still fire."""
        from click import unstyle

        from memtomem.cli import init_cmd
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "fastembed" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)
        monkeypatch.setattr(init_cmd, "_install_extras", lambda *a, **kw: False)

        state = _make_init_state(tmp_path)
        state["provider"] = "onnx"
        state["model"] = "bge-m3"
        state["dimension"] = 1024
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Missing extras: onnx" in out
        assert "memtomem[onnx]" in out


class TestInstallAxesReconcile:
    """Issue #360 Phase 1 — ``mm init`` install-type detection vs runtime
    interpreter axis reconcile.

    The wizard's cwd-filesystem axis (``_is_source_install`` /
    ``_is_project_install``) decided ``Next steps: uv run mm …``, but the
    extras probe ran in the wizard's own Python via ``find_spec``. For
    "source repo cwd + global ``mm`` (uv tool, no ``[all]``)" + "workspace
    ``.venv`` HAS ``[all]``", v0.1.18 wrongly hinted at a tool-env reinstall
    when the workspace was already set up. Phase 1 probes the workspace
    ``.venv/bin/python`` and branches the install hint by install type."""

    @staticmethod
    def _make_state_source_with_venv(tmp_path: Path) -> dict:
        """Build a source-install state whose ``.venv/bin/python`` exists.

        ``_workspace_python`` only checks ``.exists()`` so a touched empty
        file is enough; the actual subprocess call is monkeypatched."""
        state = _make_init_state(tmp_path)
        venv_bin = tmp_path / ".venv" / "bin"
        venv_bin.mkdir(parents=True, exist_ok=True)
        (venv_bin / "python").touch()
        state["_profile"] = _make_test_profile(tmp_path, kind="source")
        return state

    def test_source_cwd_workspace_has_all_suppresses_warning(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Acceptance #1: source cwd + global ``mm`` (no extras in tool env)
        + workspace ``.venv/`` HAS extras → no missing-extras warning. The
        in-process probe would say "missing" but the workspace probe is
        authoritative for what ``uv run mm`` will use."""
        from memtomem.cli import init_cmd

        # In-process probe would report missing — must be ignored.
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "fastembed" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: "fastapi", raising=False)
        # Workspace probe says everything is there.
        monkeypatch.setattr(
            init_cmd,
            "_probe_workspace_extras",
            lambda py: {"fastembed", "fastapi", "uvicorn"},
        )

        state = self._make_state_source_with_venv(tmp_path)
        state["provider"] = "onnx"
        assert init_cmd._collect_missing_extras(state) == []

    def test_source_cwd_workspace_missing_all_suggests_uv_sync_all(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Acceptance #2: workspace ``.venv/`` exists but lacks both extras
        → ``uv sync --extra all`` hint, not the tool-install reinstall."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd, "_probe_workspace_extras", lambda py: set())

        state = self._make_state_source_with_venv(tmp_path)
        state["provider"] = "onnx"
        missing = init_cmd._collect_missing_extras(state)
        assert missing == ["onnx", "web"]
        assert init_cmd._extra_install_hint(missing, state) == "uv sync --extra all"

    def test_source_cwd_workspace_missing_onnx_only_suggests_uv_sync_onnx(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Workspace has web deps but not fastembed → narrow ``uv sync
        --extra onnx`` hint."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(
            init_cmd,
            "_probe_workspace_extras",
            lambda py: {"fastapi", "uvicorn"},
        )

        state = self._make_state_source_with_venv(tmp_path)
        state["provider"] = "onnx"
        missing = init_cmd._collect_missing_extras(state)
        assert missing == ["onnx"]
        assert init_cmd._extra_install_hint(missing, state) == "uv sync --extra onnx"

    def test_pypi_install_preserves_uv_tool_reinstall_hint(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Acceptance #3: outside any source/project repo (PyPI / ``uv tool``
        install) + tool env lacks fastembed → existing tool-install
        reinstall hint preserved. v0.1.18 regression guard."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "fastembed" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)

        state = {
            "provider": "onnx",
            "rerank_enabled": False,
            "_profile": _make_test_profile(kind="pypi"),
        }
        missing = init_cmd._collect_missing_extras(state)
        assert missing == ["onnx"]
        hint = init_cmd._extra_install_hint(missing, state)
        assert hint.startswith(init_cmd._EXTRA_INSTALL_HINT_PREFIX)
        assert hint == 'uv tool install --reinstall "memtomem[onnx]"'

    def test_project_install_workspace_has_all_no_warning(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Project install (``uv add memtomem``) + workspace .venv complete
        → no warning, same path as source install."""
        from memtomem.cli import init_cmd

        venv_bin = tmp_path / ".venv" / "bin"
        venv_bin.mkdir(parents=True, exist_ok=True)
        (venv_bin / "python").touch()
        monkeypatch.setattr(
            init_cmd,
            "_probe_workspace_extras",
            lambda py: {"fastembed", "fastapi", "uvicorn"},
        )

        state = _make_init_state(tmp_path)
        state["provider"] = "onnx"
        state["_profile"] = _make_test_profile(tmp_path, kind="project")
        assert init_cmd._collect_missing_extras(state) == []

    def test_missing_venv_shows_sync_one_liner_not_warning(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Source/project install detected but ``.venv/`` is absent (fresh
        worktree) → summary shows a single ``run uv sync first`` line, NOT
        the ``[!] Missing extras`` warning. No noisy probe of the wrong
        interpreter."""
        from click import unstyle

        from memtomem.cli import init_cmd

        # No .venv created on tmp_path.
        # In-process probe would say missing — irrelevant, must be skipped.
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "fastembed" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: "fastapi", raising=False)
        set_home(monkeypatch, tmp_path)

        state = _make_init_state(tmp_path)
        state["provider"] = "onnx"
        state["model"] = "bge-m3"
        state["dimension"] = 1024
        state["_profile"] = _make_test_profile(tmp_path, kind="source")

        assert init_cmd._workspace_needs_sync(state) is True

        init_cmd._write_config_and_summary(state, tmp_path)
        out = unstyle(capsys.readouterr().out)
        assert "Workspace .venv not found" in out
        assert "uv sync --extra all" in out
        assert "Missing extras:" not in out

    def test_subprocess_timeout_falls_back_to_inproc(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """If the workspace-python subprocess fails (``None`` sentinel),
        fall back to in-process probe so the wizard still surfaces missing
        extras (better than going silent)."""
        from memtomem.cli import init_cmd

        # In-process probe says missing.
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "fastembed" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)
        # Subprocess probe fails (timeout / FileNotFoundError simulation).
        monkeypatch.setattr(init_cmd, "_probe_workspace_extras", lambda py: None)

        state = self._make_state_source_with_venv(tmp_path)
        state["provider"] = "onnx"
        assert init_cmd._collect_missing_extras(state) == ["onnx"]

    def test_inline_warned_onnx_dedup_from_summary(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Acceptance #4: when ``_step_embedding`` already printed the
        fastembed warning inline, ``_collect_missing_extras`` filters
        ``onnx`` out of the summary so the user doesn't see the same hint
        twice."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd, "_probe_workspace_extras", lambda py: set())

        state = self._make_state_source_with_venv(tmp_path)
        state["provider"] = "onnx"
        state["_extras_warned_inline"] = {"onnx"}
        # Probe says fastembed AND web missing; only web should remain.
        assert init_cmd._collect_missing_extras(state) == ["web"]

    def test_summary_source_install_workspace_has_all_silent(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """End-to-end acceptance #1: full ``_write_config_and_summary`` with
        a source-install state where the workspace probe reports complete
        extras → summary has no missing-extras warning, and ``Next steps``
        prints ``uv run mm …`` (workspace is what will run)."""
        from click import unstyle

        from memtomem.cli import init_cmd

        venv_bin = tmp_path / ".venv" / "bin"
        venv_bin.mkdir(parents=True, exist_ok=True)
        (venv_bin / "python").touch()
        # In-process probe would report missing; workspace probe is truth.
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "fastembed" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: "fastapi", raising=False)
        monkeypatch.setattr(
            init_cmd,
            "_probe_workspace_extras",
            lambda py: {"fastembed", "fastapi", "uvicorn"},
        )
        set_home(monkeypatch, tmp_path)

        state = _make_init_state(tmp_path)
        state["provider"] = "onnx"
        state["model"] = "bge-m3"
        state["dimension"] = 1024
        state["_profile"] = _make_test_profile(tmp_path, kind="source")
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Missing extras:" not in out
        assert "Workspace .venv not found" not in out
        assert "Next steps:" in out
        assert "uv run mm" in out

    def test_warning_still_fires_when_helper_declines(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Phase 2 regression: when ``_install_extras`` returns ``False``
        (user declined / subprocess failed / empty contract) the summary
        must still surface the Phase 1 ``[!] Missing extras:`` warning so
        the hint path is never silently skipped."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd, "_probe_workspace_extras", lambda py: set())
        monkeypatch.setattr(init_cmd, "_install_extras", lambda *a, **kw: False)
        set_home(monkeypatch, tmp_path)

        state = self._make_state_source_with_venv(tmp_path)
        state["provider"] = "onnx"
        state["model"] = "bge-m3"
        state["dimension"] = 1024
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Missing extras:" in out
        assert "Installed missing extras:" not in out

    def test_helper_success_skips_warning(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Phase 2: when ``_install_extras`` returns ``True`` (subprocess
        ran and exited 0) the summary shows the success line and skips the
        Phase 1 warning — a hint after a successful install would be
        noise."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd, "_probe_workspace_extras", lambda py: set())
        monkeypatch.setattr(init_cmd, "_install_extras", lambda *a, **kw: True)
        set_home(monkeypatch, tmp_path)

        state = self._make_state_source_with_venv(tmp_path)
        state["provider"] = "onnx"
        state["model"] = "bge-m3"
        state["dimension"] = 1024
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Missing extras:" not in out
        assert "Installed missing extras:" in out


class TestInstallExtrasHelper:
    """Issue #362 Phase 2 — ``_install_extras`` helper unit tests.

    Covers the ``install_type × extras × confirm`` matrix and the failure-
    fallback contract (helper returns ``False`` on every non-success path
    so the caller can fall through to :func:`_emit_missing_extras_warning`
    without losing the Phase 1 hint)."""

    def test_empty_extras_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Empty ``extras`` list → ``False`` without prompt or subprocess."""
        from memtomem.cli import init_cmd

        def _no_subprocess(*a, **kw):  # pragma: no cover - must not run
            raise AssertionError("subprocess.run must not be called for empty extras")

        monkeypatch.setattr(init_cmd.subprocess, "run", _no_subprocess)
        assert init_cmd._install_extras("source", [], workspace_dir=Path("/tmp")) is False

    def test_source_confirm_yes_runs_uv_sync(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``install_type='source'`` + nav_confirm→True → spawns ``uv sync
        --extra onnx`` with ``cwd=workspace_dir``; returns ``True`` on rc=0."""
        import subprocess as _sp

        from memtomem.cli import init_cmd

        captured: dict = {}

        def _fake_run(cmd, **kw):
            captured["cmd"] = cmd
            captured["cwd"] = kw.get("cwd")
            return _sp.CompletedProcess(cmd, returncode=0, stdout="", stderr="")

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: True)
        monkeypatch.setattr(init_cmd.subprocess, "run", _fake_run)

        assert (
            init_cmd._install_extras("source", ["onnx"], confirm=False, workspace_dir=tmp_path)
            is True
        )
        assert captured["cmd"] == ["uv", "sync", "--extra", "onnx"]
        assert captured["cwd"] == str(tmp_path)

    def test_project_confirm_yes_runs_uv_sync(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``install_type='project'`` behaves identically to ``'source'``."""
        import subprocess as _sp

        from memtomem.cli import init_cmd

        captured: dict = {}

        def _fake_run(cmd, **kw):
            captured["cmd"] = cmd
            captured["cwd"] = kw.get("cwd")
            return _sp.CompletedProcess(cmd, returncode=0, stdout="", stderr="")

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: True)
        monkeypatch.setattr(init_cmd.subprocess, "run", _fake_run)

        assert (
            init_cmd._install_extras("project", ["onnx"], confirm=False, workspace_dir=tmp_path)
            is True
        )
        assert captured["cmd"] == ["uv", "sync", "--extra", "onnx"]
        assert captured["cwd"] == str(tmp_path)

    def test_tool_confirm_yes_runs_uv_tool_reinstall(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """``install_type='tool'`` → ``uv tool install --reinstall
        "memtomem[onnx]"`` with ``cwd=None``."""
        import subprocess as _sp

        from memtomem.cli import init_cmd

        captured: dict = {}

        def _fake_run(cmd, **kw):
            captured["cmd"] = cmd
            captured["cwd"] = kw.get("cwd")
            return _sp.CompletedProcess(cmd, returncode=0, stdout="", stderr="")

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: True)
        monkeypatch.setattr(init_cmd.subprocess, "run", _fake_run)

        assert init_cmd._install_extras("tool", ["onnx"], confirm=False) is True
        assert captured["cmd"] == [
            "uv",
            "tool",
            "install",
            "--reinstall",
            "memtomem[onnx]",
        ]
        assert captured["cwd"] is None

    def test_uvx_branch_hints_and_returns_false(
        self,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """``install_type='uvx'`` is ephemeral — helper prints a hint
        about ``uvx --from`` and returns ``False`` without any
        subprocess call."""
        from click import unstyle

        from memtomem.cli import init_cmd

        def _no_subprocess(*a, **kw):  # pragma: no cover - must not run
            raise AssertionError("subprocess.run must not be called for uvx")

        monkeypatch.setattr(init_cmd.subprocess, "run", _no_subprocess)
        monkeypatch.setattr(
            init_cmd, "nav_confirm", lambda *a, **kw: True
        )  # should not reach nav_confirm either

        assert init_cmd._install_extras("uvx", ["onnx"]) is False
        out = unstyle(capsys.readouterr().out)
        assert "uvx is ephemeral" in out

    def test_multi_extras_collapse_to_all(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Two or more missing extras → helper collapses to
        ``memtomem[all]`` / ``--extra all`` (matches ``_extra_install_hint``
        and keeps ``pyproject.toml``'s public bundle contract)."""
        import subprocess as _sp

        from memtomem.cli import init_cmd

        captured: dict = {}

        def _fake_run(cmd, **kw):
            captured["cmd"] = cmd
            return _sp.CompletedProcess(cmd, returncode=0, stdout="", stderr="")

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: True)
        monkeypatch.setattr(init_cmd.subprocess, "run", _fake_run)

        assert init_cmd._install_extras("tool", ["onnx", "web"], confirm=False) is True
        assert captured["cmd"] == [
            "uv",
            "tool",
            "install",
            "--reinstall",
            "memtomem[all]",
        ]

    def test_confirm_declined_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """User presses Enter / N → ``nav_confirm`` → False → helper
        returns ``False`` and does not spawn a subprocess."""
        from memtomem.cli import init_cmd

        def _no_subprocess(*a, **kw):  # pragma: no cover - must not run
            raise AssertionError("subprocess.run must not be called when user declines")

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: False)
        monkeypatch.setattr(init_cmd.subprocess, "run", _no_subprocess)

        assert init_cmd._install_extras("tool", ["onnx"], confirm=False) is False

    def test_filenotfound_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """``FileNotFoundError`` (e.g. ``uv`` binary missing) → helper
        returns ``False`` without crashing."""
        from memtomem.cli import init_cmd

        def _raise_fnf(*a, **kw):
            raise FileNotFoundError("uv not found")

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: True)
        monkeypatch.setattr(init_cmd.subprocess, "run", _raise_fnf)

        assert init_cmd._install_extras("tool", ["onnx"], confirm=False) is False

    def test_timeout_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """``subprocess.TimeoutExpired`` → helper returns ``False``."""
        import subprocess as _sp

        from memtomem.cli import init_cmd

        def _raise_timeout(*a, **kw):
            raise _sp.TimeoutExpired(cmd=a[0] if a else "uv", timeout=600)

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: True)
        monkeypatch.setattr(init_cmd.subprocess, "run", _raise_timeout)

        assert init_cmd._install_extras("tool", ["onnx"], confirm=False) is False

    def test_nonzero_rc_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """subprocess exit code != 0 → helper returns ``False`` so the
        caller falls back to the Phase 1 hint."""
        import subprocess as _sp

        from memtomem.cli import init_cmd

        def _fake_run(cmd, **kw):
            return _sp.CompletedProcess(cmd, returncode=1, stdout="", stderr="boom")

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: True)
        monkeypatch.setattr(init_cmd.subprocess, "run", _fake_run)

        assert init_cmd._install_extras("tool", ["onnx"], confirm=False) is False

    def test_source_without_workspace_dir_returns_false(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Contract: ``source/project`` install_type requires
        ``workspace_dir``; passing ``None`` is a caller bug — helper
        fails safe (returns ``False``) rather than spawning a
        ``uv sync`` in whatever the current cwd happens to be."""
        from memtomem.cli import init_cmd

        def _no_subprocess(*a, **kw):  # pragma: no cover - must not run
            raise AssertionError("subprocess.run must not be called without workspace_dir")

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: True)
        monkeypatch.setattr(init_cmd.subprocess, "run", _no_subprocess)

        assert init_cmd._install_extras("source", ["onnx"], workspace_dir=None) is False

    def test_confirm_kwarg_passed_as_default_to_nav_confirm(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """D1 pin: helper's ``confirm`` param is forwarded verbatim to
        ``nav_confirm``'s ``default`` kwarg — ``True`` = Enter-is-Yes
        (ollama-style), ``False`` = Enter-is-No (python-extras-style)."""
        import subprocess as _sp

        from memtomem.cli import init_cmd

        seen_defaults: list[bool] = []

        def _spy_confirm(prompt, default=False):
            seen_defaults.append(default)
            return False  # always decline — we only care about the default

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd, "nav_confirm", _spy_confirm)
        monkeypatch.setattr(
            init_cmd.subprocess,
            "run",
            lambda *a, **kw: _sp.CompletedProcess([], returncode=0),
        )

        init_cmd._install_extras("tool", ["onnx"], confirm=True)
        init_cmd._install_extras("tool", ["onnx"], confirm=False)
        assert seen_defaults == [True, False]

    def test_non_tty_stdin_skips_prompt(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Scripted / non-TTY contexts (``mm init -y </dev/null``, CI jobs,
        Docker build steps) must not hit the prompt — ``click.prompt``
        raises ``Abort!`` on stdin EOF rather than returning the
        ``default=``, which would hard-exit the wizard mid-summary.

        Regression guard for the Phase 2 follow-up: without this gate,
        every scripted ``mm init`` pipeline that used to pass on v0.1.19
        would abort in v0.1.20."""
        from memtomem.cli import init_cmd

        def _no_prompt(*a, **kw):  # pragma: no cover - must not run
            raise AssertionError("nav_confirm must not be called in non-TTY context")

        def _no_subprocess(*a, **kw):  # pragma: no cover - must not run
            raise AssertionError("subprocess.run must not be called in non-TTY context")

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: False)
        monkeypatch.setattr(init_cmd, "nav_confirm", _no_prompt)
        monkeypatch.setattr(init_cmd.subprocess, "run", _no_subprocess)

        # Every install_type path that would otherwise prompt must short-circuit.
        assert init_cmd._install_extras("tool", ["onnx"], confirm=False) is False
        assert (
            init_cmd._install_extras("source", ["onnx"], confirm=False, workspace_dir=Path("/tmp"))
            is False
        )
        assert (
            init_cmd._install_extras("project", ["onnx"], confirm=False, workspace_dir=Path("/tmp"))
            is False
        )

    def test_tty_stdin_still_prompts(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Reverse pin: the non-TTY gate must not accidentally disable the
        prompt for real interactive runs. With ``isatty=True`` the helper
        still invokes ``nav_confirm`` and proceeds to subprocess on
        confirm."""
        import subprocess as _sp

        from memtomem.cli import init_cmd

        called = {"confirm": False, "run": False}

        def _spy_confirm(prompt, default=False):
            called["confirm"] = True
            return True

        def _fake_run(cmd, **kw):
            called["run"] = True
            return _sp.CompletedProcess(cmd, returncode=0, stdout="", stderr="")

        monkeypatch.setattr(init_cmd.sys.stdin, "isatty", lambda: True)
        monkeypatch.setattr(init_cmd, "nav_confirm", _spy_confirm)
        monkeypatch.setattr(init_cmd.subprocess, "run", _fake_run)

        assert init_cmd._install_extras("tool", ["onnx"], confirm=False) is True
        assert called == {"confirm": True, "run": True}


class TestMismatchBanner:
    """Issue #362 Phase 2 — cwd-vs-runtime mismatch info banner.

    Phase 1 silenced the false ``[!] Missing extras:`` warning for the
    ``source cwd + global uv-tool mm + workspace .venv has [all]``
    combination; Phase 2 adds a one-line banner explaining *why* the
    wizard is quiet. Trigger is orthogonal to extras presence — it
    fires whenever the cwd axis says source/project but
    ``sys.executable`` lives outside the workspace ``.venv/``."""

    @staticmethod
    def _source_state_with_venv(tmp_path: Path) -> dict:
        state = _make_init_state(tmp_path)
        venv_bin = tmp_path / ".venv" / "bin"
        venv_bin.mkdir(parents=True, exist_ok=True)
        (venv_bin / "python").touch()
        state["_profile"] = _make_test_profile(tmp_path, kind="source")
        state["provider"] = "onnx"
        state["model"] = "bge-m3"
        state["dimension"] = 1024
        return state

    def test_banner_fires_source_cwd_tool_runtime(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Source install + ``sys.executable`` outside workspace venv →
        banner lines present."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(
            init_cmd,
            "_probe_workspace_extras",
            lambda py: {"fastembed", "fastapi", "uvicorn"},
        )
        set_home(monkeypatch, tmp_path)

        state = self._source_state_with_venv(tmp_path)
        init_cmd._write_config_and_summary(state, tmp_path)
        out = unstyle(capsys.readouterr().out)
        assert "cwd: source / runtime: uv tool" in out
        assert "bare `mm`" in out

    def test_banner_fires_project_cwd_tool_runtime(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Project install (``uv add memtomem``) + external runtime →
        banner uses ``cwd: project``."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        venv_bin = tmp_path / ".venv" / "bin"
        venv_bin.mkdir(parents=True, exist_ok=True)
        (venv_bin / "python").touch()
        monkeypatch.setattr(
            init_cmd,
            "_probe_workspace_extras",
            lambda py: {"fastembed", "fastapi", "uvicorn"},
        )
        set_home(monkeypatch, tmp_path)

        state = _make_init_state(tmp_path)
        state["_profile"] = _make_test_profile(tmp_path, kind="project")
        state["provider"] = "onnx"
        state["model"] = "bge-m3"
        state["dimension"] = 1024
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "cwd: project / runtime: uv tool" in out

    def test_banner_silent_when_runtime_under_workspace_venv(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """``sys.executable`` points inside ``<workspace>/.venv/`` →
        runtime matches cwd axis, banner stays silent."""
        from click import unstyle

        from memtomem.cli import init_cmd

        ws_python = tmp_path / ".venv" / "bin" / "python"
        monkeypatch.setattr(init_cmd.sys, "executable", str(ws_python))
        monkeypatch.setattr(
            init_cmd,
            "_probe_workspace_extras",
            lambda py: {"fastembed", "fastapi", "uvicorn"},
        )
        set_home(monkeypatch, tmp_path)

        state = self._source_state_with_venv(tmp_path)
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "cwd: source / runtime:" not in out

    def test_banner_silent_for_pypi_install(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Neither source nor project install → no cwd axis to
        disagree with, banner stays silent regardless of runtime."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)
        monkeypatch.setattr("importlib.util.find_spec", lambda name: object())
        set_home(monkeypatch, tmp_path)

        state = _make_init_state(tmp_path)
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "cwd:" not in out
        assert "runtime:" not in out

    def test_banner_precedes_warning(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Mismatch + missing extras + helper declines → banner line
        index in output < ``[!] Missing extras:`` line index (the
        banner explains the silence *before* the warning)."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd, "_probe_workspace_extras", lambda py: set())
        monkeypatch.setattr(init_cmd, "_install_extras", lambda *a, **kw: False)
        set_home(monkeypatch, tmp_path)

        state = self._source_state_with_venv(tmp_path)
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "cwd: source / runtime: uv tool" in out
        assert "Missing extras:" in out
        banner_at = out.index("cwd: source / runtime: uv tool")
        warning_at = out.index("Missing extras:")
        assert banner_at < warning_at

    def test_banner_and_missing_venv_both_fire(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Source install + ``.venv/`` absent + external runtime → banner
        (cyan) AND ``Workspace .venv not found`` (yellow) both show."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        set_home(monkeypatch, tmp_path)

        # NOTE: no .venv/ created — triggers _workspace_needs_sync branch.
        state = _make_init_state(tmp_path)
        state["_profile"] = _make_test_profile(tmp_path, kind="source")
        state["provider"] = "onnx"
        state["model"] = "bge-m3"
        state["dimension"] = 1024
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "cwd: source / runtime: uv tool" in out
        assert "Workspace .venv not found" in out

    def test_banner_still_fires_after_successful_install(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Banner is orthogonal to extras — a successful install does
        NOT change the fact that cwd and runtime axes disagree. Banner
        must still print after ``Installed missing extras: …``."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd, "_probe_workspace_extras", lambda py: set())
        monkeypatch.setattr(init_cmd, "_install_extras", lambda *a, **kw: True)
        set_home(monkeypatch, tmp_path)

        state = self._source_state_with_venv(tmp_path)
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "cwd: source / runtime: uv tool" in out
        assert "Installed missing extras:" in out
        assert "Missing extras:" not in out


class TestRuntimeProfile:
    """Issue #363 Phase 3 — :class:`RuntimeProfile` dataclass + builder.

    Locks the contract for the single source-of-truth struct that downstream
    wizard decisions read from. Tests exercise the builder directly so a
    future install-context judgment that forgets to populate one of the
    fields surfaces in CI rather than in user-visible misclassification."""

    def test_source_install_profile_fields_populated(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Source workspace marker (``pyproject.toml`` + ``packages/``) +
        existing ``.venv/`` + matching interpreter → all six fields filled
        with the source-install variant."""
        from memtomem.cli import init_cmd

        (tmp_path / "pyproject.toml").write_text("[project]\nname='mm'\n", encoding="utf-8")
        (tmp_path / "packages").mkdir()
        venv_bin = tmp_path / ".venv" / "bin"
        venv_bin.mkdir(parents=True)
        (venv_bin / "python").touch()

        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(init_cmd.sys, "executable", str(venv_bin / "python"))
        # Avoid uvx/uv-tool detection from the parent test process's sys.prefix
        monkeypatch.setattr(init_cmd.sys, "prefix", str(tmp_path / ".venv"))

        profile = init_cmd._runtime_profile()
        assert profile.cwd_install_type == "source"
        assert profile.cwd_install_dir == tmp_path
        assert profile.runtime_interpreter == venv_bin / "python"
        assert profile.workspace_venv_path == tmp_path / ".venv"
        assert profile.runtime_matches_workspace is True
        assert profile.mm_binary_origin == "venv-relative"

    @pytest.mark.skipif(
        sys.platform == "win32",
        reason="POSIX-only fixture: monkeypatches sys.executable to "
        "'/usr/local/bin/python' which Path() normalizes to backslashes "
        "on Windows, breaking the system-path classifier",
    )
    def test_project_install_profile_no_packages_dir(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``pyproject.toml`` WITHOUT a ``packages/`` dir AND declaring
        ``memtomem`` as a dep → project install, not source. The
        ``packages/`` marker disambiguates the memtomem monorepo from a
        user project that ``uv add memtomem``'d; the dep check filters
        out foreign sibling repos that share neither marker."""
        from memtomem.cli import init_cmd

        (tmp_path / "pyproject.toml").write_text(
            "[project]\nname='userproj'\ndependencies = ['memtomem>=0.1']\n",
            encoding="utf-8",
        )
        # NOTE: no packages/ dir created
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd.sys, "prefix", "/usr/local")

        profile = init_cmd._runtime_profile()
        assert profile.cwd_install_type == "project"
        assert profile.cwd_install_dir == tmp_path
        assert profile.workspace_venv_path is None  # no .venv/ created
        assert profile.runtime_matches_workspace is False
        assert profile.mm_binary_origin == "system"

    def test_foreign_sibling_pyproject_classified_as_pypi(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Sibling project with its own ``pyproject.toml`` that does NOT
        declare ``memtomem`` as a dependency → ``pypi``, not ``project``.

        Regression for the case where running ``mm init`` from a sibling
        repo (e.g. ``memtomem-stm``) misclassified as ``project``,
        probing the foreign ``.venv`` for extras (always missing) and
        printing a ``uv sync --extra all`` hint that fails because the
        foreign ``pyproject.toml`` has no ``all`` extra."""
        from memtomem.cli import init_cmd

        # Mirrors memtomem-stm's own pyproject shape: real Python project,
        # its own deps, no memtomem reference anywhere.
        (tmp_path / "pyproject.toml").write_text(
            "[project]\n"
            "name='memtomem-stm'\n"
            "dependencies = ['mcp>=1.26.0', 'pydantic>=2.10', 'httpx>=0.28']\n"
            "\n"
            "[project.optional-dependencies]\n"
            "langfuse = ['langfuse>=4.0']\n",
            encoding="utf-8",
        )
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd.sys, "prefix", "/usr/local")

        profile = init_cmd._runtime_profile()
        assert profile.cwd_install_type == "pypi"
        assert profile.cwd_install_dir is None
        assert profile.workspace_venv_path is None

    def test_project_install_via_optional_dependencies(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """memtomem listed in ``[project.optional-dependencies]`` (e.g. a
        ``dev`` extra in a downstream user project) is enough to classify
        as a project install."""
        from memtomem.cli import init_cmd

        (tmp_path / "pyproject.toml").write_text(
            "[project]\n"
            "name='userproj'\n"
            "dependencies = ['click>=8']\n"
            "\n"
            "[project.optional-dependencies]\n"
            "dev = ['memtomem[all]>=0.1', 'pytest']\n",
            encoding="utf-8",
        )
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd.sys, "prefix", "/usr/local")

        profile = init_cmd._runtime_profile()
        assert profile.cwd_install_type == "project"
        assert profile.cwd_install_dir == tmp_path

    def test_project_install_via_dependency_groups(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """memtomem listed in PEP 735 ``[dependency-groups]`` (used by
        ``uv``-managed projects for dev-only deps) → project install."""
        from memtomem.cli import init_cmd

        (tmp_path / "pyproject.toml").write_text(
            "[project]\n"
            "name='userproj'\n"
            "dependencies = ['click>=8']\n"
            "\n"
            "[dependency-groups]\n"
            "dev = ['memtomem >= 0.1', 'ruff']\n",
            encoding="utf-8",
        )
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd.sys, "prefix", "/usr/local")

        profile = init_cmd._runtime_profile()
        assert profile.cwd_install_type == "project"
        assert profile.cwd_install_dir == tmp_path

    def test_project_install_via_legacy_uv_dev_dependencies(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """memtomem listed in legacy ``[tool.uv] dev-dependencies`` (the
        pre-PEP 735 location ``uv`` used for dev deps) → project install.
        Older ``uv`` setups that haven't migrated to ``[dependency-groups]``
        keep declaring memtomem here; the dep check has to recognize the
        legacy location too or those users fall through to ``pypi`` and
        get a ``uv tool install`` hint instead of the workspace
        ``uv sync`` hint they actually need."""
        from memtomem.cli import init_cmd

        (tmp_path / "pyproject.toml").write_text(
            "[project]\n"
            "name='userproj'\n"
            "dependencies = ['click>=8']\n"
            "\n"
            "[tool.uv]\n"
            "dev-dependencies = ['memtomem>=0.1', 'pytest']\n",
            encoding="utf-8",
        )
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd.sys, "prefix", "/usr/local")

        profile = init_cmd._runtime_profile()
        assert profile.cwd_install_type == "project"
        assert profile.cwd_install_dir == tmp_path

    def test_project_install_canonical_name_match(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Dependency name comparison normalizes per PEP 503 — ``Memtomem``,
        ``mem-tomem``, ``mem_tomem`` all match ``memtomem``. Locks the
        canonicalization seam against drift."""
        from memtomem.cli import init_cmd

        (tmp_path / "pyproject.toml").write_text(
            "[project]\nname='x'\ndependencies = ['Memtomem']\n",
            encoding="utf-8",
        )
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd.sys, "prefix", "/usr/local")

        profile = init_cmd._runtime_profile()
        assert profile.cwd_install_type == "project"

    def test_project_install_walks_past_foreign_pyproject(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """When a non-memtomem ``pyproject.toml`` sits between cwd and an
        outer memtomem-using project, the walk continues upward instead
        of returning the first hit. Ensures sub-package layouts inside
        a memtomem-using monorepo still classify correctly."""
        from memtomem.cli import init_cmd

        outer = tmp_path / "outer"
        inner = outer / "subpkg"
        inner.mkdir(parents=True)
        (outer / "pyproject.toml").write_text(
            "[project]\nname='outer'\ndependencies = ['memtomem']\n",
            encoding="utf-8",
        )
        (inner / "pyproject.toml").write_text(
            "[project]\nname='subpkg'\ndependencies = ['click']\n",
            encoding="utf-8",
        )
        monkeypatch.chdir(inner)
        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd.sys, "prefix", "/usr/local")

        profile = init_cmd._runtime_profile()
        assert profile.cwd_install_type == "project"
        assert profile.cwd_install_dir == outer

    def test_malformed_pyproject_falls_through_to_pypi(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Unreadable / malformed ``pyproject.toml`` → safe fallthrough to
        ``pypi`` (the tool-install hint always works; a workspace
        ``uv sync`` hint requires a parseable workspace)."""
        from memtomem.cli import init_cmd

        (tmp_path / "pyproject.toml").write_text("not = valid = toml\n", encoding="utf-8")
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd.sys, "prefix", "/usr/local")

        profile = init_cmd._runtime_profile()
        assert profile.cwd_install_type == "pypi"

    def test_pypi_install_profile_no_workspace_marker(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """No ancestor with ``pyproject.toml`` → ``pypi`` cwd type, no
        workspace dir, runtime never matches workspace."""
        from memtomem.cli import init_cmd

        # tmp_path has no pyproject.toml; walk up 5 levels finds none either
        # (assuming tmp_path is under a sufficiently nested location).
        nested = tmp_path / "a" / "b" / "c" / "d" / "e"
        nested.mkdir(parents=True)
        monkeypatch.chdir(nested)
        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd.sys, "prefix", "/usr/local")

        profile = init_cmd._runtime_profile()
        assert profile.cwd_install_type == "pypi"
        assert profile.cwd_install_dir is None
        assert profile.workspace_venv_path is None
        assert profile.runtime_matches_workspace is False

    def test_source_install_takes_precedence_over_foreign_nested_pyproject(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Outer ancestor with the source workspace markers
        (``pyproject.toml`` + ``packages/``) wins over a closer ancestor
        carrying a foreign ``pyproject.toml``. Locks the source-first /
        project-second invariant the dep-check fix relies on: a
        sub-package layout inside the memtomem monorepo
        (``packages/foo/pyproject.toml``) classifies as ``source``
        regardless of what the nested pyproject declares as deps.
        ``_detect_project_install`` itself returns ``None`` because its
        walk skips ancestors with ``packages/`` and the nested pyproject
        has no memtomem dep — making the source-takes-precedence
        outcome a property of both the routing AND the underlying
        function, not just one or the other."""
        from memtomem.cli import init_cmd

        outer = tmp_path / "monorepo"
        nested = outer / "packages" / "foo"
        nested.mkdir(parents=True)
        (outer / "pyproject.toml").write_text("[project]\nname='monorepo'\n", encoding="utf-8")
        (nested / "pyproject.toml").write_text(
            "[project]\nname='foo'\ndependencies = ['click']\n",
            encoding="utf-8",
        )
        monkeypatch.chdir(nested)
        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd.sys, "prefix", "/usr/local")

        profile = init_cmd._runtime_profile()
        assert profile.cwd_install_type == "source"
        assert profile.cwd_install_dir == outer
        assert init_cmd._detect_project_install() is None


class TestMmBinaryOriginDetection:
    """Issue #363 Phase 3 — first-class ``mm_binary_origin`` heuristic.

    Five-way classification (``uv-tool`` / ``uvx`` / ``venv-relative`` /
    ``system`` / ``unknown``). The ``uvx`` branch is the one that closes
    the v0.1.18 ephemeral-env hint gap; the others exist so the wizard
    has a non-default answer for every install permutation."""

    def test_origin_uvx_archive_v0_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """``sys.prefix`` containing ``uv/archive-v0/`` → ``uvx``. The
        pattern is uv's ephemeral-env cache layout."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(
            init_cmd.sys,
            "prefix",
            "/Users/x/.cache/uv/archive-v0/abcdef1234/",
        )
        origin = init_cmd._detect_mm_binary_origin(
            Path("/Users/x/.cache/uv/archive-v0/abcdef1234/bin/python"),
            runtime_matches_workspace=False,
        )
        assert origin == "uvx"

    def test_origin_uvx_builds_v0_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """``sys.prefix`` containing ``uv/builds-v0/`` → ``uvx`` (alternate
        cache layout for the build phase)."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(
            init_cmd.sys,
            "prefix",
            "/Users/x/.cache/uv/builds-v0/789abc/",
        )
        origin = init_cmd._detect_mm_binary_origin(
            Path("/Users/x/.cache/uv/builds-v0/789abc/bin/python"),
            runtime_matches_workspace=False,
        )
        assert origin == "uvx"

    def test_origin_uv_tool_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """``sys.prefix`` containing ``uv/tools/<name>/`` → ``uv-tool``."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(
            init_cmd.sys,
            "prefix",
            "/Users/x/.local/share/uv/tools/memtomem",
        )
        origin = init_cmd._detect_mm_binary_origin(
            Path("/Users/x/.local/share/uv/tools/memtomem/bin/python"),
            runtime_matches_workspace=False,
        )
        assert origin == "uv-tool"

    def test_origin_venv_relative_short_circuits(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """When the caller already detected a workspace match, the heuristic
        returns ``venv-relative`` regardless of ``sys.prefix`` content."""
        from memtomem.cli import init_cmd

        # sys.prefix would otherwise classify as uv-tool — but the caller
        # has authoritatively decided the runtime IS the workspace venv.
        monkeypatch.setattr(
            init_cmd.sys,
            "prefix",
            "/Users/x/.local/share/uv/tools/memtomem",
        )
        origin = init_cmd._detect_mm_binary_origin(
            Path("/anywhere/python"),
            runtime_matches_workspace=True,
        )
        assert origin == "venv-relative"

    @pytest.mark.skipif(
        sys.platform == "win32",
        reason="POSIX-only: tests classification of POSIX system Python paths "
        "(/usr/bin/, /opt/homebrew/, etc.) which don't exist on Windows; "
        "Path() also normalizes them to backslashes, defeating the prefix match",
    )
    def test_origin_system_paths(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Well-known system Python locations classify as ``system``."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "prefix", "/usr/local")
        for path in (
            "/usr/bin/python3",
            "/usr/local/bin/python",
            "/opt/homebrew/bin/python3.13",
            "/bin/python",
        ):
            origin = init_cmd._detect_mm_binary_origin(Path(path), runtime_matches_workspace=False)
            assert origin == "system", f"path {path} should classify as system"

    def test_origin_unknown_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Anything that doesn't match a known pattern → ``unknown``. The
        wizard treats this conservatively (defaults to ``tool`` branch in
        ``install_type_lit`` mapping)."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "prefix", "/random/path/somewhere")
        origin = init_cmd._detect_mm_binary_origin(
            Path("/random/path/somewhere/bin/python"),
            runtime_matches_workspace=False,
        )
        assert origin == "unknown"


class TestProjectInstallE2E:
    """Issue #363 Phase 3 — end-to-end coverage for the ``project_install``
    path (``uv add memtomem`` in a non-source project).

    Pre-Phase-3 the project-install branch had unit tests for individual
    helpers but no E2E summary tests; this class covers the three scenarios
    called out in the issue acceptance: workspace ``.venv`` ready, missing
    ``.venv`` (fresh clone), and re-run idempotency."""

    @staticmethod
    def _project_state(tmp_path: Path, *, with_venv: bool) -> dict:
        """Build a project-install state with optional workspace venv."""
        state = _make_init_state(tmp_path)
        state["_profile"] = _make_test_profile(tmp_path, kind="project")
        state["provider"] = "onnx"
        state["model"] = "bge-m3"
        state["dimension"] = 1024
        if with_venv:
            venv_bin = tmp_path / ".venv" / "bin"
            venv_bin.mkdir(parents=True, exist_ok=True)
            (venv_bin / "python").touch()
        return state

    def test_project_install_summary_shows_install_label(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """``Install:`` summary line says ``project`` for project installs."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(
            init_cmd, "_probe_workspace_extras", lambda py: {"fastembed", "fastapi", "uvicorn"}
        )
        set_home(monkeypatch, tmp_path)

        state = self._project_state(tmp_path, with_venv=True)
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Install:    project" in out
        assert "uv run mm" in out, "project install Next steps must use `uv run mm`"

    def test_project_install_missing_extras_uses_uv_sync_hint(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Missing extras + project install + helper declines → hint must
        be ``uv sync --extra ...`` not ``uv tool install --reinstall``."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(init_cmd, "_probe_workspace_extras", lambda py: set())
        monkeypatch.setattr(init_cmd, "_install_extras", lambda *a, **kw: False)
        set_home(monkeypatch, tmp_path)

        state = self._project_state(tmp_path, with_venv=True)
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "uv sync --extra all" in out
        assert "uv tool install --reinstall" not in out

    def test_project_install_missing_venv_shows_sync_one_liner(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Project install detected + no ``.venv/`` (fresh clone) → single
        ``Workspace .venv not found — run \\`uv sync --extra all\\` first.``
        line, NOT a ``[!] Missing extras:`` warning that would have probed
        the wrong (parent-process) interpreter."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        set_home(monkeypatch, tmp_path)

        state = self._project_state(tmp_path, with_venv=False)
        assert init_cmd._workspace_needs_sync(state) is True

        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Workspace .venv not found" in out
        assert "uv sync --extra all" in out
        assert "Missing extras:" not in out

    def test_project_install_rerun_preserves_install_decisions(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Re-running the wizard from the same project-install cwd produces
        the same ``Install:`` and ``Next steps`` lines on every run — no
        first-run-vs-later-run discrepancy in the install-context branch."""
        from click import unstyle

        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd.sys, "executable", "/usr/local/bin/python")
        monkeypatch.setattr(
            init_cmd,
            "_probe_workspace_extras",
            lambda py: {"fastembed", "fastapi", "uvicorn"},
        )
        set_home(monkeypatch, tmp_path)

        # First run
        state1 = self._project_state(tmp_path, with_venv=True)
        init_cmd._write_config_and_summary(state1, tmp_path)
        first_out = unstyle(capsys.readouterr().out)

        # Second run — fresh state dict, same cwd
        state2 = self._project_state(tmp_path, with_venv=True)
        init_cmd._write_config_and_summary(state2, tmp_path)
        second_out = unstyle(capsys.readouterr().out)

        for marker in ("Install:    project", "uv run mm"):
            assert marker in first_out
            assert marker in second_out


class TestUvxBranching:
    """Issue #363 Phase 3 — ``uvx`` install detection routes through the
    summary branch and the ``_install_extras`` uvx hint.

    The wizard now classifies its own runtime as ``uvx`` when ``sys.prefix``
    points at uv's ephemeral-env cache (``uv/archive-v*`` or ``uv/builds-v*``).
    Pre-Phase-3 this branch was dead code — ``install_type_lit`` was always
    ``"tool"`` for non-source/project paths."""

    def test_summary_install_label_is_uvx(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """``Install:`` summary line says ``uvx (ephemeral)`` when the
        wizard detects it's running under uvx."""
        from click import unstyle

        from memtomem.cli import init_cmd

        # Force uvx detection via sys.prefix.
        monkeypatch.setattr(
            init_cmd.sys,
            "prefix",
            "/Users/x/.cache/uv/archive-v0/deadbeef",
        )
        monkeypatch.setattr(
            init_cmd.sys,
            "executable",
            "/Users/x/.cache/uv/archive-v0/deadbeef/bin/python",
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)
        monkeypatch.setattr("importlib.util.find_spec", lambda name: object())
        set_home(monkeypatch, tmp_path)

        state = _make_init_state(tmp_path)
        # Explicit uvx profile — pypi classification + uvx binary origin
        # drives the "Install: uvx (ephemeral)" label and Next-steps hint.
        state["_profile"] = _make_test_profile(kind="pypi", mm_binary_origin="uvx")
        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Install:    uvx (ephemeral)" in out
        assert "uvx is ephemeral" in out

    def test_install_extras_uvx_hint_branch(
        self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """``_install_extras(install_type=\"uvx\", ...)`` prints the
        ephemeral hint and returns False — the install semantic is moot
        because the env will be destroyed on exit."""
        from click import unstyle

        from memtomem.cli import init_cmd

        # Returns False even if stdin is a TTY — uvx branch fires first.
        result = init_cmd._install_extras("uvx", ["onnx"])
        assert result is False
        out = unstyle(capsys.readouterr().out)
        assert "uvx is ephemeral" in out
        assert "memtomem[onnx]" in out


class TestFindSpecUnification:
    """Issue #363 Phase 3 acceptance D — ``__import__`` vs ``find_spec``
    semantic unification across ``mm web`` and ``mm init``.

    Both probes now use ``importlib.util.find_spec`` so the in-process
    answer to "is the package installed" is the same regardless of which
    code path asked. ``_have_module`` is the shared primitive."""

    def test_have_module_returns_true_for_installed(self) -> None:
        """``json`` is in the stdlib — always installed."""
        from memtomem.cli.init_cmd import _have_module

        assert _have_module("json") is True

    def test_have_module_returns_false_for_missing(self) -> None:
        from memtomem.cli.init_cmd import _have_module

        assert _have_module("definitely_not_a_real_package_xyz123") is False

    def test_missing_web_deps_uses_find_spec(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """``_missing_web_deps`` patches via ``find_spec`` (not
        ``__import__``) confirms the migration."""
        import memtomem.cli.web as web_mod

        original = __import__("importlib.util", fromlist=["find_spec"]).find_spec

        def fake_find_spec(name: str):  # type: ignore[no-untyped-def]
            if name == "fastapi":
                return None
            return original(name)

        monkeypatch.setattr("importlib.util.find_spec", fake_find_spec)
        assert web_mod._missing_web_deps() == "fastapi"


class TestPreservedSummary:
    """`_write_config_and_summary` flags non-default values that the wizard
    inherited from a previous config but did not ask about this run.

    Primary driver: Web UI's `save_config_overrides` dumps all mutable fields
    to config.json whenever memory-dirs are changed or a section is saved,
    silently persisting runtime values (e.g. mmr.enabled) that the user may
    not have deliberately set. The wizard cannot fix the write path, but it
    can surface the leftovers on the next `mm init`."""

    def test_fresh_init_no_mmr_section(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Fresh init must not create an ``mmr`` section in config.json.

        This is the load-bearing guard: if anyone later wires the wizard to
        write to ``mmr.*``, this test fails and forces a deliberate update to
        the preserved-summary logic (since ``wizard_touched_keys`` would then
        need to include ``mmr.*`` to avoid flagging the user's own choice)."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path)

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        assert "mmr" not in data, (
            "wizard must not create an mmr section on fresh init — "
            "if you intentionally added an MMR step, update wizard_touched_keys"
        )

    def test_preserved_non_default_shown_in_summary(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """A pre-existing ``mmr.enabled=true`` (non-default) must surface in
        the Preserved block with the ``[!]`` marker."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        (config_dir / "config.json").write_text(
            json.dumps({"mmr": {"enabled": True, "lambda_param": 0.7}}),
            encoding="utf-8",
        )

        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Preserved from existing config" in out
        assert "[!] mmr.enabled = true" in out
        assert "built-in default: false" in out

    def test_preserved_default_not_shown(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """A preserved value equal to the built-in default must NOT be flagged
        — otherwise the Preserved block becomes noise on every re-run."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        (config_dir / "config.json").write_text(
            # mmr.enabled=False is the built-in default — should stay silent.
            json.dumps({"mmr": {"enabled": False, "lambda_param": 0.7}}),
            encoding="utf-8",
        )

        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Preserved from existing config" not in out
        assert "mmr.enabled" not in out

    def test_wizard_choice_overrides_preserved(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """A field the wizard actively writes this run (e.g. search.tokenizer)
        must appear in the normal summary, never in Preserved — even if the
        previous config had the same key set."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        (config_dir / "config.json").write_text(
            json.dumps({"search": {"tokenizer": "unicode61"}}),
            encoding="utf-8",
        )

        state = _make_init_state(tmp_path)
        state["tokenizer"] = "kiwipiepy"
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        # Preserved block, if emitted, must NOT list tokenizer — wizard owns it
        preserved_idx = out.find("Preserved from existing config")
        if preserved_idx != -1:
            preserved_tail = out[preserved_idx:]
            assert "search.tokenizer" not in preserved_tail
        # And the normal summary shows the new choice
        assert "tokenizer=kiwipiepy" in out

    def test_malformed_config_graceful(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """A corrupt config.json must not crash the wizard. The bad file gets
        backed up with a ``.bak-<ts>`` suffix and the run proceeds as if
        ``existing`` were empty."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        config_path = config_dir / "config.json"
        config_path.write_text("{this is not json", encoding="utf-8")

        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "unreadable" in out
        # Backup created (suffix .json.bak-<ts>)
        backups = list(config_dir.glob("config.json.bak-*"))
        assert len(backups) == 1, f"expected exactly one backup, got {backups}"
        # New config is valid JSON
        data = json.loads(config_path.read_text(encoding="utf-8"))
        assert data["embedding"]["provider"] == "none"


class TestFreshFlag:
    """``mm init --fresh`` drops wizard-untouched canonical leftovers
    instead of merely flagging them in the Preserved block.

    Phase 2 of the leftover-cleanup work (Phase 1 = surface; Phase 2 =
    reset). Critical guards:
      - never wipes user data / credentials (preserve list)
      - always backs up before dropping anything
      - idempotent: second run with nothing to drop is a no-op on disk
      - keys outside the canonical Mem2MemConfig shape are preserved"""

    def test_fresh_drops_preserved_mmr(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Seed ``mmr.enabled=true`` (non-default, wizard-untouched) and run
        ``--fresh``: the key disappears, the section is pruned, a backup is
        written, and the Reset block names the change in
        ``before → after (default)`` form."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        config_path = config_dir / "config.json"
        # Seed only the non-default key — default-matching siblings (e.g.
        # lambda_param=0.7) wouldn't be dropped by --fresh anyway, and would
        # keep the section from being pruned, hiding the assertion below.
        config_path.write_text(
            json.dumps({"mmr": {"enabled": True}}),
            encoding="utf-8",
        )

        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path, fresh=True)

        data = json.loads(config_path.read_text(encoding="utf-8"))
        # mmr.enabled non-default → dropped; section becomes empty → pruned.
        assert "mmr" not in data, "mmr section should be pruned after --fresh"

        backups = list(config_dir.glob("config.json.bak-*"))
        assert len(backups) == 1, f"expected one backup, got {backups}"

        out = unstyle(capsys.readouterr().out)
        assert "Reset to default" in out
        assert "[-] mmr.enabled: true → false (default)" in out
        assert f"Backup saved to: {backups[0]}" in out
        assert "restart it to pick up the new config" in out

    @pytest.mark.parametrize(
        "key,value",
        [
            # Credentials
            ("embedding.api_key", "sk-test-xxx"),
            ("rerank.api_key", "voyage-xxx"),
            ("llm.api_key", "anthropic-xxx"),
            ("webhook.secret", "hmac-secret"),
            # Endpoints
            ("embedding.base_url", "https://my-openai-proxy/v1"),
            ("llm.base_url", "http://my-llm:8080"),
            ("webhook.url", "https://ops.example.com/hook"),
            # User-curated lists / rules
            ("indexing.exclude_patterns", ["*.tmp", "node_modules/"]),
            ("indexing.supported_extensions", [".rst", ".org"]),
            (
                "namespace.rules",
                [{"path_glob": "work/**", "namespace": "work"}],
            ),
            ("search.system_namespace_prefixes", ["__system__:"]),
            ("webhook.events", ["add"]),
        ],
    )
    def test_fresh_preserves_user_data_keys(
        self,
        key: str,
        value: object,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Each key in ``_FRESH_PRESERVE_KEYS`` must survive ``--fresh`` even
        when it's non-default and wizard-untouched. This is the structural
        guard tying the parametrize list to the preserve-list constant —
        adding a new key to one without the other will fail this test."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        config_path = config_dir / "config.json"

        # Seed the single user-data key into a nested config dict.
        seed: dict = {}
        cur: dict = seed
        parts = key.split(".")
        for p in parts[:-1]:
            cur = cur.setdefault(p, {})
        cur[parts[-1]] = value
        config_path.write_text(json.dumps(seed), encoding="utf-8")

        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path, fresh=True)

        data = json.loads(config_path.read_text(encoding="utf-8"))
        cur2: object = data
        for p in parts:
            assert isinstance(cur2, dict) and p in cur2, (
                f"--fresh wiped preserve-list key {key!r}; preserve list and "
                "test parametrize have drifted apart"
            )
            cur2 = cur2[p]
        assert cur2 == value, f"value for {key!r} mutated: {cur2!r} != {value!r}"

    def test_fresh_preserves_memory_dirs(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``indexing.memory_dirs`` is wizard-touched (the wizard always
        rewrites it from ``state['memory_dir']``), so ``--fresh`` must NOT
        list it as a drop candidate. The wizard's overwrite is independent
        of ``--fresh`` and is not under test here.

        Guard against a future refactor that moves memory_dirs out of
        ``init_data`` — that would silently make ``--fresh`` a memory-dir
        wiper, which is the most surprising data-loss path."""
        from memtomem.cli.init_cmd import (
            _compute_fresh_drops,
            _flatten_init_data_keys,
            _write_config_and_summary,
        )

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        config_path = config_dir / "config.json"
        config_path.write_text(
            json.dumps({"indexing": {"memory_dirs": ["~/notes", "~/docs"]}}),
            encoding="utf-8",
        )

        # Verify _compute_fresh_drops never selects memory_dirs (wizard-touched).
        state = _make_init_state(tmp_path)
        init_data_shape = {
            "embedding": {},
            "indexing": {"memory_dirs": []},
            "namespace": {},
            "search": {},
            "decay": {},
            "storage": {},
        }
        wizard_touched = _flatten_init_data_keys(init_data_shape)
        assert "indexing.memory_dirs" in wizard_touched, (
            "wizard_touched_keys must include indexing.memory_dirs — if it "
            "doesn't, --fresh would wipe user memory dirs"
        )

        existing_seed = {"indexing": {"memory_dirs": ["~/notes", "~/docs"]}}
        drops = _compute_fresh_drops(existing_seed, wizard_touched)
        assert all(k != "indexing.memory_dirs" for k, _, _ in drops), (
            "indexing.memory_dirs leaked into --fresh drop set"
        )

        # End-to-end: --fresh with memory_dirs seed completes without wiping
        # data (the wizard then overwrites it from state, which is expected).
        _write_config_and_summary(state, tmp_path, fresh=True)
        data = json.loads(config_path.read_text(encoding="utf-8"))
        assert "indexing" in data and "memory_dirs" in data["indexing"]

    def test_fresh_keeps_user_custom_keys(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A custom key outside the canonical ``Mem2MemConfig`` shape must
        survive ``--fresh`` regardless of preserve-list contents — drop logic
        only considers canonical keys."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        config_path = config_dir / "config.json"
        config_path.write_text(
            json.dumps(
                {
                    "my_extension": {"foo": 1, "bar": "two"},
                    "mmr": {"enabled": True},
                }
            ),
            encoding="utf-8",
        )

        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path, fresh=True)

        data = json.loads(config_path.read_text(encoding="utf-8"))
        assert data.get("my_extension") == {"foo": 1, "bar": "two"}
        # Canonical leftover still gets dropped.
        assert "mmr" not in data

    def test_fresh_no_op_when_nothing_to_reset(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """If the existing config has no wizard-untouched non-default keys,
        ``--fresh`` writes no backup and prints the no-leftovers message."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        # Seed only fields the wizard will overwrite — nothing untouched
        # that differs from default.
        config_path = config_dir / "config.json"
        config_path.write_text(
            json.dumps({"embedding": {"provider": "ollama"}}),
            encoding="utf-8",
        )

        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path, fresh=True)

        backups = list(config_dir.glob("config.json.bak-*"))
        assert backups == [], f"unexpected backup created: {backups}"

        out = unstyle(capsys.readouterr().out)
        assert "no wizard-untouched leftovers to reset" in out
        assert "Reset to default" not in out

    def test_fresh_with_non_interactive(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """``--fresh`` is compatible with ``-y`` (non-interactive) — drops
        proceed without any prompt, the Reset block still prints, and the
        normal Setup-complete summary is unaffected."""
        from click import unstyle
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        config_path = config_dir / "config.json"
        config_path.write_text(
            json.dumps({"mmr": {"enabled": True}}),
            encoding="utf-8",
        )

        result = CliRunner().invoke(
            init,
            ["-y", "--fresh", "--provider", "none", "--memory-dir", str(tmp_path / "mem")],
        )
        assert result.exit_code == 0, result.output

        out = unstyle(result.output)
        assert "Reset to default" in out
        assert "Setup complete!" in out  # Phase 1 summary still emitted

        data = json.loads(config_path.read_text(encoding="utf-8"))
        assert "mmr" not in data

    def test_fresh_handles_malformed_config(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Malformed ``config.json`` + ``--fresh`` falls through to the
        existing unreadable-config recovery path (timestamped backup +
        empty merge base) and emits the no-leftovers Reset message — the
        post-recovery ``existing`` is empty so nothing is droppable."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        config_path = config_dir / "config.json"
        config_path.write_text("{not valid json", encoding="utf-8")

        state = _make_init_state(tmp_path)
        _write_config_and_summary(state, tmp_path, fresh=True)

        out = unstyle(capsys.readouterr().out)
        assert "unreadable" in out  # malformed-config recovery hit
        # The unreadable-config path creates exactly one backup (.bak-<ts>);
        # --fresh's own backup logic skips because no drops were possible.
        backups = list(config_dir.glob("config.json.bak-*"))
        assert len(backups) == 1, f"expected one backup, got {backups}"
        assert "no wizard-untouched leftovers to reset" in out

    def test_fresh_is_idempotent(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Running ``--fresh`` twice in a row: first run drops + backs up,
        second run is a pure no-op on disk (no second backup, config
        bit-identical). Guard against accidental backup churn."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        # Pin time.time so a same-second double-invoke can't collide on
        # the .bak-<ts> filename even on fast machines.
        clock = {"t": 1_700_000_000}
        monkeypatch.setattr("memtomem.cli.init_cmd.time.time", lambda: clock["t"])

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        config_path = config_dir / "config.json"
        config_path.write_text(
            json.dumps({"mmr": {"enabled": True}, "search": {"rrf_k": 120}}),
            encoding="utf-8",
        )

        state = _make_init_state(tmp_path)

        _write_config_and_summary(state, tmp_path, fresh=True)
        first_pass = config_path.read_text(encoding="utf-8")
        first_backups = sorted(config_dir.glob("config.json.bak-*"))
        assert len(first_backups) == 1

        clock["t"] += 1  # second run gets a different ts in case it backs up
        _write_config_and_summary(state, tmp_path, fresh=True)
        second_pass = config_path.read_text(encoding="utf-8")
        second_backups = sorted(config_dir.glob("config.json.bak-*"))

        assert second_pass == first_pass, "second --fresh run mutated config"
        assert second_backups == first_backups, (
            "second --fresh run created a backup despite zero drops"
        )

    def test_fresh_atomic_write_failure_keeps_backup_and_original(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """``--fresh`` now writes via ``_atomic_write_json``. If the atomic
        ``os.replace`` fails mid-write, both the pre-fresh ``config.json``
        and the backup must stay intact, and no ``.config.*.tmp`` orphan
        should linger in ``~/.memtomem/``. This is the failure-mode
        complement to ``test_fresh_drops_preserved_mmr``: the happy path is
        already covered there.
        """
        import os as _os

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        config_path = config_dir / "config.json"
        original = json.dumps({"mmr": {"enabled": True}})
        config_path.write_text(original, encoding="utf-8")

        def fail_replace(*args, **kwargs):
            raise OSError("simulated replace failure")

        monkeypatch.setattr(_os, "replace", fail_replace)

        state = _make_init_state(tmp_path)
        with pytest.raises(OSError, match="simulated replace failure"):
            _write_config_and_summary(state, tmp_path, fresh=True)

        assert config_path.read_text(encoding="utf-8") == original
        backups = list(config_dir.glob("config.json.bak-*"))
        assert len(backups) == 1, f"expected backup to survive, got {backups}"
        orphans = [
            p
            for p in config_dir.iterdir()
            if p.name.startswith(".config.") and p.name.endswith(".tmp")
        ]
        assert not orphans, f"orphan tmp file(s) after failed atomic write: {orphans}"


class TestMcpPasteHints:
    """The wizard's MCP step writes a Claude-Code-scoped ``.mcp.json`` and must
    surface what the user still has to do for Cursor / Windsurf / Claude
    Desktop / Antigravity CLI / Gemini CLI / Kimi CLI (none of which auto-load
    the project file).

    Regression for #246: earlier wording implied ``.mcp.json`` is the config
    file for those editors, which it isn't — each has its own canonical path."""

    def test_choice_2_emits_per_editor_paste_hints(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        from click import unstyle

        from memtomem.cli.init_cmd import _claude_desktop_config_hint, _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        monkeypatch.chdir(tmp_path)

        state = _make_init_state(tmp_path)
        state["mcp_choice"] = 2
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "MCP config: wrote ./.mcp.json" in out
        assert "Cursor" in out and "~/.cursor/mcp.json" in out
        assert "Windsurf" in out and "~/.codeium/windsurf/mcp_config.json" in out
        assert "Claude Desktop" in out
        assert _claude_desktop_config_hint() in out
        assert "Antigravity CLI → paste into ~/.gemini/antigravity-cli/mcp_config.json" in out
        # Antigravity CLI's mcp_config.json carries "type": "stdio" on each entry,
        # which the generated .mcp.json omits — the hint must flag it.
        assert '(add "type": "stdio" to the memtomem entry)' in out
        assert "Gemini CLI" in out and "~/.gemini/settings.json" in out
        assert "Kimi CLI" in out and "~/.kimi/mcp.json" in out
        assert "Claude Code picks up ./.mcp.json in this project automatically" in out

    def test_choice_3_skip_emits_no_hints(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Skipping MCP setup must not print paste hints — there's no file
        yet for the user to paste."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        state = _make_init_state(tmp_path)  # mcp_choice = 3 by default
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "MCP config:" not in out
        assert "~/.cursor/mcp.json" not in out

    def test_step_mcp_menu_does_not_imply_autoload(
        self,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """The ``[2]`` menu line must scope ``.mcp.json`` to Claude Code and
        frame other editors as "copy into". The old "(for Cursor, Windsurf,
        etc.)" wording implied auto-compat and is explicitly ruled out."""
        from click import unstyle

        from memtomem.cli.init_cmd import _step_mcp

        # Stub the interactive prompt so the menu renders without stdin.
        monkeypatch.setattr("memtomem.cli.init_cmd.nav_prompt", lambda *a, **kw: 3)

        state: dict = {}
        _step_mcp(state)

        out = unstyle(capsys.readouterr().out)
        assert "(for Cursor, Windsurf, etc.)" not in out, "old auto-compat wording leaked back in"
        assert "Claude Code project scope" in out
        assert "copy into your editor's config file" in out
        assert "Kimi CLI" in out

    def test_choice_4_writes_kimi_mcp_json(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        share_dir = tmp_path / "kimi-share"
        monkeypatch.setenv("KIMI_SHARE_DIR", str(share_dir))
        set_home(monkeypatch, tmp_path)
        monkeypatch.chdir(tmp_path)

        state = _make_init_state(tmp_path)
        state["mcp_choice"] = 4
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        mcp_path = share_dir / "mcp.json"
        assert f"Kimi CLI MCP config: wrote {mcp_path}" in out
        data = json.loads(mcp_path.read_text(encoding="utf-8"))
        assert data["mcpServers"]["memtomem"]["command"] in {"uvx", "uv"}
        assert "memtomem-server" in data["mcpServers"]["memtomem"]["args"]
        assert not (tmp_path / ".mcp.json").exists()


class TestMcpChoiceOneClaudeAddBranches:
    """``mcp_choice == 1`` invokes ``claude mcp add`` and must distinguish
    three failure modes — the old code collapsed all of them into
    "'claude' not found", which lied to users who had ``claude`` on PATH
    but already had a ``memtomem`` user-scope entry registered (rerun of
    ``mm init``).
    """

    @staticmethod
    def _state(tmp_path: Path) -> dict:
        state = _make_init_state(tmp_path)
        state["mcp_choice"] = 1
        return state

    def test_returncode_zero_emits_configured(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        from click import unstyle

        from memtomem.cli import init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(
            init_cmd,
            "_run",
            lambda cmd, timeout=10: subprocess.CompletedProcess(cmd, 0, "", ""),
        )

        init_cmd._write_config_and_summary(self._state(tmp_path), tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Claude Code: configured (user scope)" in out
        # success path must NOT also write the .mcp.json fallback.
        assert not (tmp_path / ".mcp.json").exists()
        assert "MCP config: wrote ./.mcp.json" not in out

    def test_already_exists_stderr_skips_fallback(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Reruns of ``mm init`` (or any case where ``memtomem`` is already
        in the user-scope claude config) must report success and skip the
        ``.mcp.json`` fallback — Claude Code is already covered by the
        existing user-scope entry, so a project-scope file would only add
        duplicate state for the user to clean up.
        """
        from click import unstyle

        from memtomem.cli import init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(
            init_cmd,
            "_run",
            lambda cmd, timeout=10: subprocess.CompletedProcess(
                cmd, 1, "", "MCP server memtomem already exists in user config\n"
            ),
        )

        init_cmd._write_config_and_summary(self._state(tmp_path), tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Claude Code: already registered (user scope) — skipped" in out
        # Regression: the old wording must not leak back in.
        assert "'claude' not found" not in out
        # No fallback file should be written — Claude Code already has the
        # user-scope entry covering it.
        assert not (tmp_path / ".mcp.json").exists()
        assert "MCP config: wrote ./.mcp.json" not in out
        # And paste-hints (Cursor / Windsurf / Claude Desktop / Antigravity CLI
        # / Gemini CLI) must NOT fire either — the user opted for "auto-register Claude
        # Code", and the existing user-scope entry already covers it. A
        # future refactor that hoists _emit_mcp_paste_hints() out of the
        # generic-failure block would silently spam unrelated paste paths
        # in this branch; pin against that.
        assert "~/.cursor/mcp.json" not in out
        assert "~/.codeium/windsurf/mcp_config.json" not in out

    def test_generic_nonzero_surfaces_stderr_and_falls_back(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Any other non-zero return must NOT be reported as "claude not
        found" — claude clearly ran. Surface the first stderr line so the
        user can see what actually broke before we fall back to
        ``.mcp.json``.
        """
        from click import unstyle

        from memtomem.cli import init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(
            init_cmd,
            "_run",
            lambda cmd, timeout=10: subprocess.CompletedProcess(
                cmd, 2, "", "claude: error: scope `user` is locked\n"
            ),
        )

        init_cmd._write_config_and_summary(self._state(tmp_path), tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Claude Code: claude mcp add failed" in out
        assert "scope `user` is locked" in out
        # Regression: misleading wording must not fire here.
        assert "'claude' not found" not in out
        # Generic failure DOES write the fallback — Claude Code is not yet
        # configured, so user needs the project-scope file to recover.
        assert (tmp_path / ".mcp.json").exists()
        assert "MCP config: wrote ./.mcp.json" in out

    def test_filenotfound_keeps_legacy_not_found_message(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """When the ``claude`` binary is genuinely missing the original
        message is correct and must stay — this is the only branch where
        "'claude' not found" reflects reality.
        """
        from click import unstyle

        from memtomem.cli import init_cmd

        def _missing(cmd: list[str], timeout: int = 10) -> subprocess.CompletedProcess:
            raise FileNotFoundError(2, "No such file or directory: 'claude'")

        set_home(monkeypatch, tmp_path)
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(init_cmd, "_run", _missing)

        init_cmd._write_config_and_summary(self._state(tmp_path), tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Claude Code: 'claude' not found" in out
        assert (tmp_path / ".mcp.json").exists()

    def test_timeout_falls_back_to_not_found_branch(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """``subprocess.TimeoutExpired`` shares the legacy
        ``FileNotFoundError`` branch (claude hung past 10s). The current
        wording reuses "'claude' not found" — not strictly accurate for
        timeouts but the same .mcp.json fallback is the right recovery,
        so we just pin the existing behavior. If we ever split these
        cases, this test wants a distinct message for timeouts.
        """
        from click import unstyle

        from memtomem.cli import init_cmd

        def _hang(cmd: list[str], timeout: int = 10) -> subprocess.CompletedProcess:
            raise subprocess.TimeoutExpired(cmd=cmd, timeout=timeout)

        set_home(monkeypatch, tmp_path)
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(init_cmd, "_run", _hang)

        init_cmd._write_config_and_summary(self._state(tmp_path), tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Claude Code: 'claude' not found" in out
        # Same fallback path as FileNotFoundError — write .mcp.json so the
        # user can still wire Claude Code (or other editors) up manually.
        assert (tmp_path / ".mcp.json").exists()
        assert "MCP config: wrote ./.mcp.json" in out


class TestClaudeDesktopConfigHint:
    """The Claude Desktop paste-hint must point at the real Claude Desktop
    config path for the user's OS, not the macOS-only path that used to be
    hardcoded.

    Regression for #328: Linux/Windows users following the old hint created
    an unused file at a macOS-specific path and left their real Claude
    Desktop config untouched."""

    @pytest.mark.parametrize(
        "platform,expected",
        [
            ("darwin", "~/Library/Application Support/Claude/claude_desktop_config.json"),
            ("linux", "~/.config/Claude/claude_desktop_config.json"),
            ("linux2", "~/.config/Claude/claude_desktop_config.json"),
            ("freebsd14", "~/.config/Claude/claude_desktop_config.json"),
            ("win32", r"%APPDATA%\Claude\claude_desktop_config.json"),
        ],
    )
    def test_helper_returns_per_os_path(
        self,
        monkeypatch: pytest.MonkeyPatch,
        platform: str,
        expected: str,
    ) -> None:
        from memtomem.cli.init_cmd import _claude_desktop_config_hint

        monkeypatch.setattr("memtomem.cli.init_cmd.sys.platform", platform)

        assert _claude_desktop_config_hint() == expected

    def test_emit_hints_does_not_leak_darwin_path_on_linux(
        self,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Negative guard for the actual bug — `_emit_mcp_paste_hints` must
        not echo the macOS ``Library/Application Support`` path on Linux."""
        from click import unstyle

        from memtomem.cli.init_cmd import _emit_mcp_paste_hints

        monkeypatch.setattr("memtomem.cli.init_cmd.sys.platform", "linux")
        _emit_mcp_paste_hints()

        out = unstyle(capsys.readouterr().out)
        assert "Library/Application Support" not in out
        assert "~/.config/Claude/claude_desktop_config.json" in out


class TestProviderDirsStep:
    """Step 4 — opt-in indexing of provider memory folders.

    Replaces the legacy ``ensure_auto_discovered_dirs`` runtime path. The
    step is wizard-driven (per-category nav_confirm), and a non-interactive
    counterpart is exposed via ``--include-provider``.
    """

    def test_step_skips_when_no_provider_dirs_detected(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Detection returns all-empty → step records [] without prompting."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )
        # Failing nav_confirm guarantees the test breaks if a prompt fires.
        monkeypatch.setattr(
            init_cmd,
            "nav_confirm",
            lambda *a, **kw: pytest.fail("nav_confirm called when no dirs detected"),
        )

        state: dict = {}
        init_cmd._step_provider_dirs(state)
        assert state["provider_dirs"] == []

    def test_step_appends_only_accepted_categories(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """User says yes to Claude memory and Codex, no to plans → only the
        accepted categories land in state["provider_dirs"]."""
        from memtomem.cli import init_cmd

        claude_mem = tmp_path / "claude-mem"
        plans = tmp_path / "plans"
        codex = tmp_path / "codex"
        for p in (claude_mem, plans, codex):
            p.mkdir()

        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {
                "claude-memory": [claude_mem],
                "claude-plans": [plans],
                "codex": [codex],
            },
        )
        # Order of prompts in _step_provider_dirs: claude-memory, claude-plans, codex
        answers = iter([True, False, True])
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: next(answers))

        state: dict = {}
        init_cmd._step_provider_dirs(state)
        assert state["provider_dirs"] == [str(claude_mem), str(codex)]

    def test_step_skips_categories_with_no_dirs(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """Categories with no detected dirs do NOT get a prompt — only the
        present categories show up. Counts from the answers iterator are a
        load-bearing guard against an extra prompt sneaking in."""
        from memtomem.cli import init_cmd

        plans = tmp_path / "plans"
        plans.mkdir()
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [plans], "codex": []},
        )
        answers = iter([True])  # exactly one prompt expected (plans only)
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: next(answers))

        state: dict = {}
        init_cmd._step_provider_dirs(state)
        assert state["provider_dirs"] == [str(plans)]
        with pytest.raises(StopIteration):
            next(answers)  # no extra prompts fired

    def test_step_includes_multiple_claude_memory_projects(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """A single 'yes' to Claude memory adds every detected per-project dir,
        not just the first — grouping the prompt avoids per-project noise."""
        from memtomem.cli import init_cmd

        p1 = tmp_path / "p1" / "memory"
        p2 = tmp_path / "p2" / "memory"
        for p in (p1, p2):
            p.mkdir(parents=True)
            (p / "MEMORY.md").write_text("# index", encoding="utf-8")

        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [p1, p2], "claude-plans": [], "codex": []},
        )
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: True)

        state: dict = {}
        init_cmd._step_provider_dirs(state)
        assert state["provider_dirs"] == [str(p1), str(p2)]

    def test_write_merges_provider_dirs_into_memory_dirs(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``_write_config_and_summary`` concatenates ``state["memory_dir"]``
        with ``state["provider_dirs"]`` (deduped) when persisting
        ``indexing.memory_dirs``. Also pins ``auto_discover: false`` so a
        fresh wizard run on a legacy install doesn't keep migrating.

        Both ``state["memory_dir"]`` and ``state["provider_dirs"]`` sit
        under ``tmp_path`` (the test ``$HOME``), so the writer's home-
        relative serialization rewrites them as ``~/...`` on disk.
        """
        from memtomem.cli.init_cmd import _write_config_and_summary
        from memtomem.config import _portable_path_str

        set_home(monkeypatch, tmp_path)
        provider_dir = tmp_path / "claude-mem"
        provider_dir.mkdir()

        state = _make_init_state(tmp_path)
        state["provider_dirs"] = [str(provider_dir)]
        _write_config_and_summary(state, tmp_path)

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        dirs = data["indexing"]["memory_dirs"]
        memory_dir_portable = _portable_path_str(state["memory_dir"])
        provider_dir_portable = _portable_path_str(str(provider_dir))
        assert memory_dir_portable in dirs
        assert provider_dir_portable in dirs
        assert dirs.count(memory_dir_portable) == 1  # no duplicates
        assert data["indexing"]["auto_discover"] is False


class TestCategorizeMemoryDir:
    """Per-path classifier that powers both wizard grouping and the Web
    UI's ``category`` field on ``GET /api/memory-dirs/status``."""

    def test_claude_memory_per_project(self) -> None:
        from memtomem.config import categorize_memory_dir

        assert (
            categorize_memory_dir("/home/alice/.claude/projects/my-proj/memory") == "claude-memory"
        )

    def test_claude_plans(self) -> None:
        from memtomem.config import categorize_memory_dir

        assert categorize_memory_dir("/home/alice/.claude/plans") == "claude-plans"

    def test_codex_memories(self) -> None:
        from memtomem.config import categorize_memory_dir

        assert categorize_memory_dir("/home/alice/.codex/memories") == "codex"

    def test_trailing_slash_normalisation(self) -> None:
        from memtomem.config import categorize_memory_dir

        # Trailing slash(es) should not change the category.
        assert categorize_memory_dir("/u/.codex/memories/") == "codex"
        assert categorize_memory_dir("/u/.codex/memories///") == "codex"
        assert categorize_memory_dir("/u/.claude/plans/") == "claude-plans"

    def test_user_fallback(self) -> None:
        from memtomem.config import categorize_memory_dir

        assert categorize_memory_dir("/home/alice/notes") == "user"
        assert categorize_memory_dir("/srv/shared/memory") == "user"

    def test_lookalike_paths_do_not_match(self) -> None:
        """Paths that mention ``claude``/``codex`` but aren't the canonical
        provider layout must stay in ``user`` — classification is anchored
        at the end of the path, not a substring match."""
        from memtomem.config import categorize_memory_dir

        # Looks like a provider path but isn't — user took a backup/copy.
        assert categorize_memory_dir("/archive/.codex/memories-backup") == "user"
        # No leading dot — not the real ``.claude`` dir.
        assert categorize_memory_dir("/work/claude/plans/foo") == "user"
        # Extra suffix past the canonical name.
        assert categorize_memory_dir("/u/.claude/plans/2024") == "user"

    def test_pathlib_input(self, tmp_path: Path) -> None:
        """Accepts ``Path`` as well as ``str`` — the indexing stats call
        passes whatever is in the config list through verbatim."""
        from memtomem.config import categorize_memory_dir

        p = Path("/u/.codex/memories")
        assert categorize_memory_dir(p) == "codex"
        assert categorize_memory_dir(tmp_path) == "user"

    def test_provider_categories_tuple_is_derived(self) -> None:
        """Belt-and-suspenders: the exported category tuple is built from
        the same pattern table, so adding a row can't silently diverge."""
        from memtomem.config import (
            PROVIDER_DIR_CATEGORIES,
            _PROVIDER_CATEGORY_PATTERNS,
        )

        assert PROVIDER_DIR_CATEGORIES == tuple(cat for cat, _ in _PROVIDER_CATEGORY_PATTERNS)


class TestMemoryDirKind:
    """Two-bucket classifier (``memory`` vs ``general``) layered on top
    of :func:`categorize_memory_dir`. Drives the Web UI Sources page
    sub-toggle so users can see agent/user memory separately from
    arbitrary indexed RAG folders."""

    def test_known_provider_layouts_are_memory(self) -> None:
        from memtomem.config import memory_dir_kind

        assert memory_dir_kind("/home/alice/.claude/projects/p/memory") == "memory"
        assert memory_dir_kind("/home/alice/.claude/plans") == "memory"
        assert memory_dir_kind("/home/alice/.codex/memories") == "memory"

    def test_user_dir_with_memory_segment_is_memory(self) -> None:
        from memtomem.config import memory_dir_kind

        # Common layout — user curates a top-level memory folder.
        assert memory_dir_kind("/Users/alice/memories") == "memory"
        assert memory_dir_kind("/Users/alice/Documents/memory") == "memory"

    def test_segment_match_is_case_insensitive(self) -> None:
        """macOS' default APFS is case-insensitive, so the same folder
        surfaces as ``Memories`` or ``memories`` depending on how the
        user typed it. Both must classify as memory — otherwise the
        Sources view would partition the same folder differently
        across machines."""
        from memtomem.config import memory_dir_kind

        assert memory_dir_kind("/Users/alice/Memories") == "memory"
        assert memory_dir_kind("/Users/alice/MEMORIES") == "memory"
        assert memory_dir_kind("/Users/alice/Documents/Memory") == "memory"

    def test_user_dir_without_memory_segment_is_general(self) -> None:
        from memtomem.config import memory_dir_kind

        # The common case — arbitrary RAG-style folder added by the user.
        assert memory_dir_kind("/Users/alice/work/docs") == "general"
        assert memory_dir_kind("/Users/alice/projects/notes") == "general"
        assert memory_dir_kind("/srv/shared/planning") == "general"

    def test_compound_word_segment_does_not_match(self) -> None:
        """The heuristic compares whole path segments, not substrings —
        ``memory-game`` is not the segment ``memory`` and stays general.
        Documents the heuristic boundary so future tweaks don't regress."""
        from memtomem.config import memory_dir_kind

        assert memory_dir_kind("/code/memory-game") == "general"
        assert memory_dir_kind("/code/in-memory-cache") == "general"
        assert memory_dir_kind("/code/memorial") == "general"

    def test_heuristic_is_aggressive_for_segment_matches(self) -> None:
        """Documented limitation: any path with an exact ``memory`` /
        ``memories`` segment lands in the Memory bucket, even when the
        intent is clearly not agent memory. A future PR can add an
        explicit per-dir override; until then these classify as memory."""
        from memtomem.config import memory_dir_kind

        # App cache happens to use a ``memory`` segment.
        assert memory_dir_kind("/Users/alice/Library/Caches/some-app/memory") == "memory"
        # Code repo with a folder happening to be named ``memory``.
        assert memory_dir_kind("/Users/alice/projects/foo/memory") == "memory"
        # Photo archive named ``memories`` — not a RAG corpus, but the
        # heuristic can't tell.
        assert memory_dir_kind("/Users/alice/Documents/memories/photos") == "memory"

    def test_pathlib_input(self) -> None:
        from memtomem.config import memory_dir_kind

        assert memory_dir_kind(Path("/u/.codex/memories")) == "memory"
        assert memory_dir_kind(Path("/u/work/notes")) == "general"


class TestDetectProviderDirsRoundtrip:
    """Drift guard: every path ``_detect_provider_dirs`` returns must
    classify back to the same category via ``categorize_memory_dir``.

    Without this, the "single source of truth" claim of issue #299 is
    only enforced by code review — exactly the pattern called out in
    ``feedback_silent_policy_enforcement_gap.md``.
    """

    def test_roundtrip_over_all_categories(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from memtomem.config import _detect_provider_dirs, categorize_memory_dir

        set_home(monkeypatch, tmp_path)

        # Populate one entry per category so every branch of detection
        # fires and every resulting Path goes through the classifier.
        proj_mem = tmp_path / ".claude" / "projects" / "demo" / "memory"
        proj_mem.mkdir(parents=True)
        (proj_mem / "note.md").write_text("x", encoding="utf-8")
        plans = tmp_path / ".claude" / "plans"
        plans.mkdir(parents=True)
        codex = tmp_path / ".codex" / "memories"
        codex.mkdir(parents=True)

        grouped = _detect_provider_dirs()

        seen_any = False
        for expected_cat, paths in grouped.items():
            for p in paths:
                seen_any = True
                assert categorize_memory_dir(p) == expected_cat, (
                    f"_detect_provider_dirs bucketed {p!r} as {expected_cat!r} "
                    f"but categorize_memory_dir classifies it as "
                    f"{categorize_memory_dir(p)!r}"
                )
        assert seen_any, "fixture should produce at least one discovered dir"


def test_provider_categories_vocabulary_locked() -> None:
    """Derived categories must match the declared vocabulary — regression
    guard for silent expansion of ``_PROVIDER_CATEGORY_PATTERNS`` before
    RFC #304 settles the hierarchy."""
    from memtomem.config import (
        _PROVIDER_CATEGORY_PATTERNS,
        _VALID_PROVIDER_CATEGORIES,
    )

    derived = {cat for cat, _ in _PROVIDER_CATEGORY_PATTERNS} | {"user"}
    assert derived == _VALID_PROVIDER_CATEGORIES


def test_vocabulary_lock_message_references_rfc() -> None:
    """Assertion message itself must reference RFC #304 so a future breaker
    following the ``AssertionError`` traceback finds the rationale.

    Checks the exported ``_VOCABULARY_LOCK_MESSAGE`` constant directly (not
    a wide ``inspect.getsource`` scan), so unrelated ``#304`` mentions
    elsewhere in ``config.py`` cannot mask a regression where the RFC
    reference is dropped from the actual assertion message."""
    from memtomem.config import _VOCABULARY_LOCK_MESSAGE

    assert "#304" in _VOCABULARY_LOCK_MESSAGE


def test_category_to_provider_keys_cover_categories() -> None:
    """``_CATEGORY_TO_PROVIDER`` must tag every known category — the Web UI
    relies on ``memory_dir_stats()`` emitting a non-empty ``provider`` on
    every row. Without this lock, adding a category row without tagging
    its vendor would silently fall back to ``"user"``."""
    from memtomem.config import _CATEGORY_TO_PROVIDER, _VALID_PROVIDER_CATEGORIES

    assert set(_CATEGORY_TO_PROVIDER.keys()) == _VALID_PROVIDER_CATEGORIES


def test_providers_vocabulary_locked() -> None:
    """Declared vendor set must match the tags used by
    ``_CATEGORY_TO_PROVIDER``. Mirror of
    :func:`test_provider_categories_vocabulary_locked` on the vendor axis
    — without this, a future ``_CATEGORY_TO_PROVIDER["skills"] =
    "anthropic"`` would introduce a new provider silently."""
    from memtomem.config import _CATEGORY_TO_PROVIDER, _VALID_PROVIDERS

    assert set(_CATEGORY_TO_PROVIDER.values()) == _VALID_PROVIDERS


def test_provider_vocabulary_lock_message_references_rfc() -> None:
    """Provider-axis lock message must reference RFC #304 for the same
    reason as the category-axis message: an ``AssertionError`` traceback
    should lead a future reader straight to the rationale."""
    from memtomem.config import _PROVIDER_VOCABULARY_LOCK_MESSAGE

    assert "#304" in _PROVIDER_VOCABULARY_LOCK_MESSAGE


def test_category_to_provider_key_drift_message_names_mapping() -> None:
    """Key-axis drift message must name ``_CATEGORY_TO_PROVIDER`` so a
    contributor following the ``AssertionError`` traceback edits the
    mapping rather than re-reading the category vocabulary — the two
    live in the same file but fixing the wrong one produces a confusing
    second failure."""
    from memtomem.config import _CATEGORY_TO_PROVIDER_KEY_DRIFT_MESSAGE

    assert "_CATEGORY_TO_PROVIDER" in _CATEGORY_TO_PROVIDER_KEY_DRIFT_MESSAGE
    assert "#304" in _CATEGORY_TO_PROVIDER_KEY_DRIFT_MESSAGE


def test_provider_for_category_roundtrip() -> None:
    """Public helper must agree with the internal mapping for every known
    category and fall back to ``"user"`` for anything unknown (mirrors
    :func:`~memtomem.config.categorize_memory_dir`'s user-default)."""
    from memtomem.config import (
        _CATEGORY_TO_PROVIDER,
        _VALID_PROVIDER_CATEGORIES,
        provider_for_category,
    )

    for cat in _VALID_PROVIDER_CATEGORIES:
        assert provider_for_category(cat) == _CATEGORY_TO_PROVIDER[cat]
    assert provider_for_category("not-a-real-category") == "user"


def test_provider_category_literal_matches_frozenset() -> None:
    """``_VALID_PROVIDER_CATEGORIES`` is derived from
    ``get_args(ProviderCategory)``, so by construction they cannot drift.
    This test documents that contract — if a future refactor inlines the
    frozenset back into a literal, the explicit ``get_args`` equality
    failure will catch it before mypy's implicit exhaustiveness does."""
    from typing import get_args

    from memtomem.config import ProviderCategory, _VALID_PROVIDER_CATEGORIES

    assert set(get_args(ProviderCategory)) == _VALID_PROVIDER_CATEGORIES


def test_provider_name_literal_matches_frozenset() -> None:
    """Mirror of :func:`test_provider_category_literal_matches_frozenset`
    on the vendor axis."""
    from typing import get_args

    from memtomem.config import ProviderName, _VALID_PROVIDERS

    assert set(get_args(ProviderName)) == _VALID_PROVIDERS


class TestIncludeProviderFlag:
    """Non-interactive ``--include-provider`` wires categories into
    ``indexing.memory_dirs`` without prompting."""

    def test_flag_resolves_categories_to_dirs(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        codex = tmp_path / "codex"
        codex.mkdir()
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": [codex]},
        )

        runner = CliRunner()
        result = runner.invoke(
            init,
            [
                "--non-interactive",
                "--memory-dir",
                str(tmp_path / "memories"),
                "--include-provider",
                "codex",
                "--mcp",
                "skip",
            ],
        )
        assert result.exit_code == 0, result.output

        from memtomem.config import _portable_path_str

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        assert _portable_path_str(str(codex)) in data["indexing"]["memory_dirs"]

    def test_flag_silently_skips_unavailable_categories(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Asking for a category with no detected dirs is a no-op, not an error."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        result = runner.invoke(
            init,
            [
                "--non-interactive",
                "--memory-dir",
                str(tmp_path / "memories"),
                "--include-provider",
                "codex",
                "--mcp",
                "skip",
            ],
        )
        assert result.exit_code == 0, result.output
        from memtomem.config import _portable_path_str

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        # Only the user's primary memory_dir survives; no fake codex path.
        assert data["indexing"]["memory_dirs"] == [_portable_path_str(str(tmp_path / "memories"))]

    def test_no_flag_writes_no_provider_dirs(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Without ``--include-provider``, non-interactive mode adds zero
        provider dirs even if the dev machine has them."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        # Pretend a Codex dir exists; the test asserts it's NOT pulled in.
        codex = tmp_path / "codex"
        codex.mkdir()
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": [codex]},
        )

        runner = CliRunner()
        result = runner.invoke(
            init,
            [
                "--non-interactive",
                "--memory-dir",
                str(tmp_path / "memories"),
                "--mcp",
                "skip",
            ],
        )
        assert result.exit_code == 0, result.output
        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        assert str(codex) not in data["indexing"]["memory_dirs"]


def test_memory_dirs_env_requires_json_array(monkeypatch: pytest.MonkeyPatch) -> None:
    """Documentation examples of MEMTOMEM_INDEXING__MEMORY_DIRS must be
    encoded as a JSON array string. A bare path crashes pydantic-settings.

    This also guards the `mm init` wizard: previously it wrote a plain-string
    env var into `.mcp.json`, causing the MCP server to fail on startup with
    `SettingsError: error parsing value for field "indexing"`.
    """
    # Clear any inherited state first.
    monkeypatch.delenv("MEMTOMEM_INDEXING__MEMORY_DIRS", raising=False)
    # Prevent ~/.memtomem/config.json overrides from masking env-var behavior.
    import memtomem.config as _cfg

    monkeypatch.setattr(_cfg, "load_config_overrides", lambda c: None)

    # The documented format (JSON array literal) must parse cleanly.
    monkeypatch.setenv("MEMTOMEM_INDEXING__MEMORY_DIRS", '["~/notes"]')
    cfg = Mem2MemConfig()
    assert cfg.indexing.memory_dirs == [Path("~/notes")]

    # The bad format (plain string) must fail — this is the bug the docs and
    # wizard previously produced.
    monkeypatch.setenv("MEMTOMEM_INDEXING__MEMORY_DIRS", "~/notes")
    with pytest.raises(Exception):  # pydantic SettingsError wraps JSONDecodeError
        Mem2MemConfig()


class TestProviderPresetRules:
    """Wizard appends matching ``NamespacePolicyRule`` entries to
    ``namespace.rules`` when the user accepts a provider category (#296).

    Without the preset, ``auto_ns`` collapses paths like
    ``~/.claude/projects/FOO/memory/foo.md`` to the ``default`` namespace
    because ``memory`` is a non-discriminating basename. The preset routes
    the file under ``claude:FOO`` via the ``{ancestor:1}`` placeholder.
    """

    @pytest.mark.parametrize(
        ("category", "expected_glob", "expected_ns"),
        [
            ("claude-memory", "~/.claude/projects/*/memory/**", "claude:{ancestor:1}"),
            ("claude-plans", "~/.claude/plans/**", "claude-plans"),
        ],
    )
    def test_step_collects_rule_for_accepted_category(
        self,
        monkeypatch: pytest.MonkeyPatch,
        tmp_path: Path,
        category: str,
        expected_glob: str,
        expected_ns: str,
    ) -> None:
        from memtomem.cli import init_cmd

        target = tmp_path / category
        target.mkdir()
        grouped = {"claude-memory": [], "claude-plans": [], "codex": []}
        grouped[category] = [target]
        monkeypatch.setattr("memtomem.config._detect_provider_dirs", lambda: grouped)
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: True)

        state: dict = {}
        init_cmd._step_provider_dirs(state)

        rules = state.get("provider_rules", [])
        assert len(rules) == 1
        cat, rule = rules[0]
        assert cat == category
        assert rule == {"path_glob": expected_glob, "namespace": expected_ns}

    def test_step_collects_codex_split_rules(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """Codex maps to an ordered 3-rule split: the per-subdir rules
        (rollout_summaries, extensions) MUST precede the ``codex:global``
        catch-all so first-match-wins routes each subtree correctly."""
        from memtomem.cli import init_cmd

        target = tmp_path / "codex"
        target.mkdir()
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": [target]},
        )
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: True)

        state: dict = {}
        init_cmd._step_provider_dirs(state)

        rules = state.get("provider_rules", [])
        assert [(cat, r["namespace"]) for cat, r in rules] == [
            ("codex", "codex:rollout_summaries"),
            ("codex", "codex:extensions"),
            ("codex", "codex:global"),
        ]
        globs = [r["path_glob"] for _, r in rules]
        # catch-all is last; subdir rules precede it (first-match-wins)
        assert globs[-1] == "~/.codex/memories/**"
        assert globs.index("~/.codex/memories/rollout_summaries/**") < globs.index(
            "~/.codex/memories/**"
        )

    def test_step_skips_rule_when_category_declined(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        from memtomem.cli import init_cmd

        codex = tmp_path / "codex"
        codex.mkdir()
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": [codex]},
        )
        monkeypatch.setattr(init_cmd, "nav_confirm", lambda *a, **kw: False)

        state: dict = {}
        init_cmd._step_provider_dirs(state)
        assert state.get("provider_rules") == []

    def test_write_appends_preset_rules_to_config(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Accepted categories land as ``NamespacePolicyRule`` entries in
        the persisted ``namespace.rules`` list."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        state = _make_init_state(tmp_path)
        state["provider_rules"] = [
            (
                "claude-memory",
                {"path_glob": "~/.claude/projects/*/memory/**", "namespace": "claude:{ancestor:1}"},
            ),
            ("codex", {"path_glob": "~/.codex/memories/**", "namespace": "codex:global"}),
        ]
        _write_config_and_summary(state, tmp_path)

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        rules = data["namespace"]["rules"]
        assert {
            "path_glob": "~/.claude/projects/*/memory/**",
            "namespace": "claude:{ancestor:1}",
        } in rules
        assert {"path_glob": "~/.codex/memories/**", "namespace": "codex:global"} in rules

    def test_write_is_idempotent_on_rerun(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Running ``mm init`` twice with the same accepted categories does
        not duplicate rules — dedup is by ``path_glob``."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        state = _make_init_state(tmp_path)
        state["provider_rules"] = [
            (
                "codex",
                {
                    "path_glob": "~/.codex/memories/rollout_summaries/**",
                    "namespace": "codex:rollout_summaries",
                },
            ),
            (
                "codex",
                {"path_glob": "~/.codex/memories/extensions/**", "namespace": "codex:extensions"},
            ),
            ("codex", {"path_glob": "~/.codex/memories/**", "namespace": "codex:global"}),
        ]

        _write_config_and_summary(state, tmp_path)
        _write_config_and_summary(state, tmp_path)

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        rules = data["namespace"]["rules"]
        # each codex glob appears exactly once — dedup by path_glob, and the
        # re-run's ``codex:global`` is not mistaken for the legacy flat rule.
        for glob in (
            "~/.codex/memories/rollout_summaries/**",
            "~/.codex/memories/extensions/**",
            "~/.codex/memories/**",
        ):
            matching = [r for r in rules if r.get("path_glob") == glob]
            assert len(matching) == 1, f"expected one {glob} rule, got {matching}"

    def test_write_migrates_legacy_flat_codex_rule(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Upgrade path: a config written by the old flat-``codex`` preset is
        migrated on re-run. The stale ``~/.codex/memories/** -> codex`` rule is
        removed and the per-subdir split takes its place — in order, with no
        dead/duplicate catch-all (Codex review Blocker #1)."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        # Seed a config carrying the exact legacy flat codex preset.
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        (config_dir / "config.json").write_text(
            json.dumps(
                {
                    "namespace": {
                        "rules": [{"path_glob": "~/.codex/memories/**", "namespace": "codex"}]
                    }
                }
            ),
            encoding="utf-8",
        )

        state = _make_init_state(tmp_path)
        state["provider_rules"] = [
            (
                "codex",
                {
                    "path_glob": "~/.codex/memories/rollout_summaries/**",
                    "namespace": "codex:rollout_summaries",
                },
            ),
            (
                "codex",
                {"path_glob": "~/.codex/memories/extensions/**", "namespace": "codex:extensions"},
            ),
            ("codex", {"path_glob": "~/.codex/memories/**", "namespace": "codex:global"}),
        ]
        _write_config_and_summary(state, tmp_path)

        data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
        rules = data["namespace"]["rules"]
        namespaces = [r["namespace"] for r in rules]
        globs = [r["path_glob"] for r in rules]

        # Legacy flat codex is gone; the split is present.
        assert "codex" not in namespaces
        assert {"codex:rollout_summaries", "codex:extensions", "codex:global"} <= set(namespaces)
        # Exactly one catch-all, and subdir rules precede it (first-match-wins).
        assert globs.count("~/.codex/memories/**") == 1
        assert globs.index("~/.codex/memories/rollout_summaries/**") < globs.index(
            "~/.codex/memories/**"
        )
        assert globs.index("~/.codex/memories/extensions/**") < globs.index("~/.codex/memories/**")

    def test_write_preserves_user_rule_on_same_glob_diff_namespace(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """User-authored rule with the preset's ``path_glob`` but a custom
        ``namespace`` wins — the wizard does not overwrite it."""
        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        # Seed config with a user-authored rule using the preset's glob.
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        (config_dir / "config.json").write_text(
            json.dumps(
                {
                    "namespace": {
                        "rules": [{"path_glob": "~/.codex/memories/**", "namespace": "my-codex"}]
                    }
                }
            ),
            encoding="utf-8",
        )

        state = _make_init_state(tmp_path)
        # Propose the real codex split. The custom catch-all wins for the
        # ``**`` glob, AND the subdir rules must NOT be appended behind it —
        # they would be unreachable (first-match-wins) and dead config.
        state["provider_rules"] = [
            (
                "codex",
                {
                    "path_glob": "~/.codex/memories/rollout_summaries/**",
                    "namespace": "codex:rollout_summaries",
                },
            ),
            (
                "codex",
                {"path_glob": "~/.codex/memories/extensions/**", "namespace": "codex:extensions"},
            ),
            ("codex", {"path_glob": "~/.codex/memories/**", "namespace": "codex:global"}),
        ]
        _write_config_and_summary(state, tmp_path)

        data = json.loads((config_dir / "config.json").read_text(encoding="utf-8"))
        rules = data["namespace"]["rules"]
        # Only the user's custom catch-all remains: codex:global deduped away,
        # the two subdir rules dropped as shadowed (not written behind it).
        assert len(rules) == 1, f"no shadowed subdir rules should be written, got {rules}"
        assert rules[0]["namespace"] == "my-codex", "user override should be preserved"

    def test_dedup_expanduser_normalizes_both_sides(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``~/.codex/memories/**`` and the expanded absolute form are
        treated as the same rule for dedup purposes so manually edited
        configs stay idempotent under ``mm init`` re-runs."""
        from memtomem.cli.init_cmd import _rule_matches_existing

        existing = [
            {"path_glob": str(Path("~/.codex/memories/**").expanduser()), "namespace": "codex"},
        ]
        assert _rule_matches_existing("~/.codex/memories/**", existing) is True
        assert _rule_matches_existing("~/.claude/plans/**", existing) is False

    def test_glob_shadows_is_separator_agnostic(self) -> None:
        """``_glob_shadows`` must handle backslash separators — on Windows
        ``Path.expanduser()`` yields ``C:\\Users\\…\\**``, and a separator-naive
        ``/**`` check silently stops detecting shadowing there (regression: the
        codex split shipped subdir rules behind a user catch-all on Windows)."""
        from memtomem.cli.init_cmd import _glob_shadows

        # Windows-style backslash globs (already expanded, no tilde).
        outer_win = r"C:\Users\me\.codex\memories\**"
        inner_win = r"C:\Users\me\.codex\memories\rollout_summaries\**"
        assert _glob_shadows(outer_win, inner_win) is True
        # POSIX equivalents still shadow.
        assert (
            _glob_shadows(
                "/Users/me/.codex/memories/**",
                "/Users/me/.codex/memories/rollout_summaries/**",
            )
            is True
        )
        # An unrelated tree never shadows, on either separator.
        assert _glob_shadows(r"C:\Users\me\.gemini\**", inner_win) is False
        # A non-recursive outer glob never acts as a catch-all.
        assert _glob_shadows(r"C:\Users\me\.codex\memories\*", inner_win) is False

    def test_include_provider_flag_writes_rule_when_dirs_present(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Non-interactive ``--include-provider`` parity: rule is appended
        the same way as the interactive per-category prompt."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        codex = tmp_path / "codex"
        codex.mkdir()
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": [codex]},
        )

        result = CliRunner().invoke(
            init,
            [
                "--non-interactive",
                "--memory-dir",
                str(tmp_path / "memories"),
                "--include-provider",
                "codex",
                "--mcp",
                "skip",
            ],
        )
        assert result.exit_code == 0, result.output
        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        rules = data["namespace"]["rules"]
        assert any(r.get("path_glob") == "~/.codex/memories/**" for r in rules)

    def test_include_provider_flag_skips_rule_when_no_dirs(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Asking for a category whose dirs don't exist on this machine
        does NOT register a rule — parity with the interactive step skipping
        empty categories keeps config clean of dead rules."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        result = CliRunner().invoke(
            init,
            [
                "--non-interactive",
                "--memory-dir",
                str(tmp_path / "memories"),
                "--include-provider",
                "codex",
                "--mcp",
                "skip",
            ],
        )
        assert result.exit_code == 0, result.output
        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        rules = data.get("namespace", {}).get("rules", [])
        assert rules == []

    def test_banner_lists_proposed_and_skipped(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Banner shows `+` for new rules and `⏭` for skipped (existing).
        Pre-write output so a partial failure surfaces what was attempted."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        # Seed: claude-plans rule already present, codex not. (A pre-existing
        # rule with a namespace that is *not* a superseded preset is kept and
        # reported as skipped, not replaced.)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        (config_dir / "config.json").write_text(
            json.dumps(
                {
                    "namespace": {
                        "rules": [{"path_glob": "~/.claude/plans/**", "namespace": "claude-plans"}]
                    }
                }
            ),
            encoding="utf-8",
        )

        state = _make_init_state(tmp_path)
        state["provider_rules"] = [
            ("claude-plans", {"path_glob": "~/.claude/plans/**", "namespace": "claude-plans"}),
            (
                "codex",
                {
                    "path_glob": "~/.codex/memories/rollout_summaries/**",
                    "namespace": "codex:rollout_summaries",
                },
            ),
        ]
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Namespace rules:" in out
        assert "+ ~/.codex/memories/rollout_summaries/**" in out
        assert "⏭ ~/.claude/plans/**" in out

    def test_banner_reports_replaced_legacy_rule(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Migrating the flat codex preset surfaces a ``↻`` replaced line so
        the rewrite is not silent."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        (config_dir / "config.json").write_text(
            json.dumps(
                {
                    "namespace": {
                        "rules": [{"path_glob": "~/.codex/memories/**", "namespace": "codex"}]
                    }
                }
            ),
            encoding="utf-8",
        )
        state = _make_init_state(tmp_path)
        state["provider_rules"] = [
            ("codex", {"path_glob": "~/.codex/memories/**", "namespace": "codex:global"}),
        ]
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "↻ ~/.codex/memories/**" in out
        assert "replaced legacy 'codex'" in out

    def test_banner_reports_shadowed_subdir_rule(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """A subdir rule made unreachable by a user's custom catch-all is
        surfaced with a ``⚠`` line rather than silently appended as dead
        config (Codex review Major)."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        (config_dir / "config.json").write_text(
            json.dumps(
                {
                    "namespace": {
                        "rules": [{"path_glob": "~/.codex/memories/**", "namespace": "my-codex"}]
                    }
                }
            ),
            encoding="utf-8",
        )
        state = _make_init_state(tmp_path)
        state["provider_rules"] = [
            (
                "codex",
                {
                    "path_glob": "~/.codex/memories/rollout_summaries/**",
                    "namespace": "codex:rollout_summaries",
                },
            ),
            ("codex", {"path_glob": "~/.codex/memories/**", "namespace": "codex:global"}),
        ]
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "⚠ ~/.codex/memories/rollout_summaries/**" in out
        assert "shadowed by an existing rule" in out

    def test_banner_silent_when_no_proposals(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """No accepted categories → no banner noise."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        state = _make_init_state(tmp_path)
        state["provider_rules"] = []
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Namespace rules:" not in out

    def test_banner_all_skipped_one_liner(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Every proposed rule already exists → one-liner confirms the
        wizard looked, instead of staying silent and leaving the user to
        wonder whether rules were considered at all."""
        from click import unstyle

        from memtomem.cli.init_cmd import _write_config_and_summary

        set_home(monkeypatch, tmp_path)
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        # Use a non-superseded preset (claude-plans) so the proposed rule is
        # skipped (already present), not migrated.
        (config_dir / "config.json").write_text(
            json.dumps(
                {
                    "namespace": {
                        "rules": [{"path_glob": "~/.claude/plans/**", "namespace": "claude-plans"}]
                    }
                }
            ),
            encoding="utf-8",
        )
        state = _make_init_state(tmp_path)
        state["provider_rules"] = [
            ("claude-plans", {"path_glob": "~/.claude/plans/**", "namespace": "claude-plans"}),
        ]
        _write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "already managed" in out
        assert "nothing to add" in out

    def test_preset_templates_only_use_allowed_placeholders(self) -> None:
        """Lock the placeholder vocabulary until RFC #304 decides the
        hierarchy format — extending the preset table beyond
        ``{ancestor:1}`` needs an explicit review, not a silent edit."""
        from memtomem.cli.init_cmd import (
            _PROVIDER_RULE_TEMPLATES,
            _VALID_PRESET_PLACEHOLDERS,
        )

        for _category, rules in _PROVIDER_RULE_TEMPLATES.items():
            for _glob, namespace in rules:
                if "{" in namespace or "}" in namespace:
                    # If placeholders are used, they must be one of the allowed set.
                    assert any(p in namespace for p in _VALID_PRESET_PLACEHOLDERS), (
                        f"namespace {namespace!r} uses a placeholder outside "
                        f"_VALID_PRESET_PLACEHOLDERS={_VALID_PRESET_PLACEHOLDERS}"
                    )


def test_valid_presets_frozenset_matches_literal() -> None:
    """``_VALID_PRESETS`` is derived from ``get_args(PresetName)``; adding a
    preset means changing the Literal, and the frozenset picks it up via
    the shared ``get_args`` path. Mirrors
    :func:`test_provider_category_literal_matches_frozenset` for the
    preset vocabulary."""
    from typing import get_args

    from memtomem.cli.init_presets import PresetName, _VALID_PRESETS

    assert set(get_args(PresetName)) == _VALID_PRESETS


class TestPresetSelection:
    """``mm init`` preset picker and ``--preset`` / ``--advanced`` flags."""

    def test_minimal_preset_applies_bm25_only(self) -> None:
        """Minimal preset keeps the no-download baseline: provider=none,
        no reranker, unicode61 tokenizer."""
        from memtomem.cli.init_cmd import _apply_preset

        state: dict = {}
        _apply_preset(state, "minimal")

        assert state["provider"] == "none"
        assert state["model"] == ""
        assert state["dimension"] == 0
        assert state["rerank_enabled"] is False
        assert state["tokenizer"] == "unicode61"
        assert state["enable_auto_ns"] is False
        assert state["_preset_applied"] == "minimal"

    def test_english_preset_applies_onnx_and_rerank(self) -> None:
        """English preset: ONNX bge-small-en-v1.5 + English rerank +
        auto-discover providers (the recommended default)."""
        from memtomem.cli.init_cmd import _apply_preset

        state: dict = {}
        _apply_preset(state, "english")

        assert state["provider"] == "onnx"
        assert state["model"] == "bge-small-en-v1.5"
        assert state["dimension"] == 384
        assert state["rerank_enabled"] is True
        assert state["rerank_model"] == "Xenova/ms-marco-MiniLM-L-6-v2"
        assert state["tokenizer"] == "unicode61"
        assert state["enable_auto_ns"] is True

    def test_korean_preset_applies_bge_m3_and_kiwipiepy(self) -> None:
        """Korean preset: ONNX bge-m3 + kiwipiepy tokenizer + multilingual
        reranker (the Korean-optimized bundle)."""
        from memtomem.cli.init_cmd import _apply_preset

        state: dict = {}
        _apply_preset(state, "korean")

        assert state["provider"] == "onnx"
        assert state["model"] == "bge-m3"
        assert state["dimension"] == 1024
        assert state["rerank_enabled"] is True
        assert state["rerank_model"] == "jinaai/jina-reranker-v2-base-multilingual"
        assert state["tokenizer"] == "kiwipiepy"
        assert state["enable_auto_ns"] is True

    def test_preset_flag_non_interactive(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``mm init --preset korean -y`` writes the Korean preset's fields
        into config.json with no prompting."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        result = runner.invoke(
            init,
            [
                "--non-interactive",
                "--preset",
                "korean",
                "--memory-dir",
                str(tmp_path / "memories"),
                "--mcp",
                "skip",
            ],
        )
        assert result.exit_code == 0, result.output

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        assert data["embedding"]["provider"] == "onnx"
        assert data["embedding"]["model"] == "bge-m3"
        assert data["embedding"]["dimension"] == 1024
        assert data["rerank"]["model"] == "jinaai/jina-reranker-v2-base-multilingual"
        assert data["search"]["tokenizer"] == "kiwipiepy"
        assert data["namespace"]["enable_auto_ns"] is True

    def test_preset_flag_rejects_unknown(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Invalid preset names are rejected by ``click.Choice`` (exit 2)."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        runner = CliRunner()
        result = runner.invoke(
            init,
            [
                "--non-interactive",
                "--preset",
                "invalid-name",
            ],
        )
        assert result.exit_code == 2
        assert "invalid" in result.output.lower()

    def test_preset_plus_explicit_flag_override_non_interactive(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``--preset korean --provider ollama -y`` — preset sets the baseline
        then the explicit ``--provider`` flag wins; tokenizer stays kiwipiepy
        since there's no ``--tokenizer`` override."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        result = runner.invoke(
            init,
            [
                "--non-interactive",
                "--preset",
                "korean",
                "--provider",
                "ollama",
                "--memory-dir",
                str(tmp_path / "memories"),
                "--mcp",
                "skip",
            ],
        )
        assert result.exit_code == 0, result.output

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        assert data["embedding"]["provider"] == "ollama"
        # model defaulted to ollama's nomic-embed-text since no --model given.
        assert data["embedding"]["model"] == "nomic-embed-text"
        # tokenizer still kiwipiepy from the preset — not overridden.
        assert data["search"]["tokenizer"] == "kiwipiepy"

    def test_override_same_provider_preserves_preset_model(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``--preset korean --provider onnx -y`` — ``--provider`` re-states
        the preset's own provider, so the default-model reset must NOT fire.
        Regression for a clobber where same-provider override dropped
        korean's bge-m3 back to onnx's ``all-MiniLM-L6-v2`` default."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        result = runner.invoke(
            init,
            [
                "--non-interactive",
                "--preset",
                "korean",
                "--provider",
                "onnx",
                "--memory-dir",
                str(tmp_path / "memories"),
                "--mcp",
                "skip",
            ],
        )
        assert result.exit_code == 0, result.output

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        # Preset's model/dimension survive because the provider didn't change.
        assert data["embedding"]["provider"] == "onnx"
        assert data["embedding"]["model"] == "bge-m3"
        assert data["embedding"]["dimension"] == 1024
        assert data["search"]["tokenizer"] == "kiwipiepy"

    def test_preset_plus_explicit_flag_override_interactive(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Same precedence rule applies when ``--preset`` is combined with
        explicit flags in interactive mode (picker skipped because --preset
        is given). Review-raised #2: both paths must call the override
        pass so the combination behaves consistently."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        # Interactive preset branch: feed memory_dir accept + create=Y + mcp=3.
        result = runner.invoke(
            init,
            [
                "--preset",
                "korean",
                "--provider",
                "ollama",
            ],
            input=f"{tmp_path / 'memories'}\ny\n3\n",
        )
        assert result.exit_code == 0, result.output

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        # Flag wins over preset in the interactive preset path too.
        assert data["embedding"]["provider"] == "ollama"
        assert data["embedding"]["model"] == "nomic-embed-text"
        # And the preset's tokenizer is still there.
        assert data["search"]["tokenizer"] == "kiwipiepy"

    def test_preset_and_advanced_mutually_exclusive(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``--preset`` and ``--advanced`` can't both be set — the user has
        to pick one path."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        runner = CliRunner()
        result = runner.invoke(
            init,
            ["--preset", "korean", "--advanced"],
        )
        assert result.exit_code != 0
        assert "mutually exclusive" in result.output.lower()

    def test_advanced_flag_runs_full_wizard(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``--advanced`` forces the 10-step wizard and skips the preset
        picker entirely."""
        from click.testing import CliRunner

        import memtomem.cli.init_cmd as init_cmd

        set_home(monkeypatch, tmp_path)

        recorded: dict[str, object] = {}

        def fake_run_steps(steps, state):  # type: ignore[no-untyped-def]
            recorded["step_count"] = len(steps)
            recorded["step_names"] = [s.__name__ for s in steps]
            # Populate a valid state dict so _write_config_and_summary succeeds.
            state.update(
                {
                    "provider": "none",
                    "model": "",
                    "dimension": 0,
                    "api_key": "",
                    "rerank_enabled": False,
                    "memory_dir": str(tmp_path / "memories"),
                    "db_path": str(tmp_path / ".memtomem" / "memtomem.db"),
                    "enable_auto_ns": False,
                    "default_ns": "default",
                    "top_k": 10,
                    "tokenizer": "unicode61",
                    "decay_enabled": False,
                    "mcp_choice": 3,
                    "settings_hooks": False,
                    "provider_dirs": [],
                    "provider_rules": [],
                }
            )

        monkeypatch.setattr(init_cmd, "run_steps", fake_run_steps)

        runner = CliRunner()
        result = runner.invoke(init_cmd.init, ["--advanced"])
        assert result.exit_code == 0, result.output

        # Full 10-step wizard — not the 3-step preset follow-up.
        assert recorded["step_count"] == 10
        assert "_step_embedding" in recorded["step_names"]
        assert "_step_reranker" in recorded["step_names"]
        assert "_step_preset_picker" not in recorded["step_names"]

    def test_provider_dirs_auto_with_no_detection(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Fresh machine with no Claude/Codex dirs — preset path emits an
        explicit banner, not a silent skip (review #3)."""
        from memtomem.cli.init_cmd import _apply_preset, _step_provider_dirs_auto

        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        state: dict = {}
        _apply_preset(state, "english")

        # Capture click.echo output by running through a CliRunner-style
        # isolated context; simpler: just call and assert state + rely on
        # coverage (banner is cosmetic). Contract: empty lists, no crash.
        _step_provider_dirs_auto(state)
        assert state["provider_dirs"] == []
        assert state["provider_rules"] == []

    def test_non_tty_without_flag_errors(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Without ``--preset`` / ``-y`` / ``--advanced``, a non-TTY
        invocation must error cleanly — not hang trying to read a
        prompt from a closed stdin (review #5)."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        runner = CliRunner()
        # CliRunner's stdin is a pipe → isatty() == False.
        result = runner.invoke(init, [])
        assert result.exit_code != 0
        assert "non-interactive terminal" in result.output.lower()

    def test_non_tty_with_y_flag_runs_minimal(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``mm init -y`` with no other flags still behaves as
        ``--preset minimal -y`` — existing scripted callers unchanged."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        result = runner.invoke(
            init,
            [
                "--non-interactive",
                "--memory-dir",
                str(tmp_path / "memories"),
                "--mcp",
                "skip",
            ],
        )
        assert result.exit_code == 0, result.output

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        # Minimal defaults: BM25-only, no reranker.
        assert data["embedding"]["provider"] == "none"
        assert data["embedding"]["dimension"] == 0
        assert "rerank" not in data
        assert data["search"]["tokenizer"] == "unicode61"

    def test_get_preset_raises_value_error_for_unknown(self) -> None:
        """``click.Choice`` blocks invalid CLI input; the in-process helper
        surfaces a clear ``ValueError`` listing valid names for programmatic
        callers (tests, future reuse)."""
        from memtomem.cli.init_presets import _VALID_PRESETS, get_preset

        with pytest.raises(ValueError) as exc:
            get_preset("nonsense")
        assert "nonsense" in str(exc.value)
        for name in _VALID_PRESETS:
            assert name in str(exc.value)

    def test_preset_rerun_preserves_non_init_fields(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Re-running ``mm init --preset korean -y`` on a config with a
        user-added ``mmr.enabled=true`` must leave ``mmr`` intact — preset
        path inherits the ``_write_config_and_summary`` read-merge-write
        contract covered by :class:`TestInitConfigMerge`."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )
        config_dir = tmp_path / ".memtomem"
        config_dir.mkdir()
        config_path = config_dir / "config.json"
        config_path.write_text(
            json.dumps({"mmr": {"enabled": True, "lambda_param": 0.3}}),
            encoding="utf-8",
        )

        runner = CliRunner()
        result = runner.invoke(
            init,
            [
                "--non-interactive",
                "--preset",
                "korean",
                "--memory-dir",
                str(tmp_path / "memories"),
                "--mcp",
                "skip",
            ],
        )
        assert result.exit_code == 0, result.output

        data = json.loads(config_path.read_text(encoding="utf-8"))
        assert data["embedding"]["model"] == "bge-m3"  # preset applied
        assert data["mmr"]["enabled"] is True  # non-init field survived
        assert data["mmr"]["lambda_param"] == 0.3


class TestPresetWizardBackNav:
    """(#371) "b" at the memory-dir step must navigate back to the preset
    picker in the default interactive path. Previously the picker and its
    follow-up steps lived in separate ``run_steps`` calls, so ``_step_memory_dir``
    was index 0 of the second call and "b" silently re-prompted instead of
    going back. The combined-run_steps fix puts the picker at index 0 of one
    list so ``StepBack`` from memory_dir decrements cleanly into the picker.
    """

    def test_back_at_memory_dir_returns_to_preset_picker(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Pick english, hit "b" at memory-dir, re-pick korean. The final
        config must reflect korean (bge-m3 + kiwipiepy), proving the picker
        ran a second time AND the re-pick actually overwrote the first
        preset's state."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        # CliRunner swaps sys.stdin mid-invoke so `sys.stdin.isatty` can't be
        # patched through that boundary; init_cmd exposes the ``_isatty``
        # seam for exactly this case.
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        # 1) picker: "2" (english, default)
        # 2) memory_dir: "b"  → back to picker
        # 3) picker: "3" (korean)
        # 4) memory_dir: the tmp path
        # 5) "create it?": y
        # 6) mcp: "3" (skip)
        memory_dir = tmp_path / "memories"
        result = runner.invoke(init, [], input=f"2\nb\n3\n{memory_dir}\ny\n3\n")
        assert result.exit_code == 0, result.output

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        # Korean re-pick, not the initial English pick.
        assert data["embedding"]["model"] == "bge-m3"
        assert data["search"]["tokenizer"] == "kiwipiepy"

    def test_advanced_from_picker_routes_to_full_wizard(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Picking ``[4] Advanced`` at the picker raises ``_AdvancedSelected``,
        which must break out of the combined ``run_steps`` and dispatch the
        full 10-step wizard. Regression guard against losing the advanced
        dispatch when refactoring the picker flow."""
        from click.testing import CliRunner

        import memtomem.cli.init_cmd as init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)

        recorded: list[list[str]] = []
        original_run_steps = init_cmd.run_steps

        def tracking_run_steps(steps, state=None):  # type: ignore[no-untyped-def]
            names = [s.__name__ for s in steps]
            recorded.append(names)
            # First call: combined picker + follow-ups — let the real
            # run_steps execute so the picker can raise _AdvancedSelected.
            if "_step_preset_picker" in names:
                return original_run_steps(steps, state)
            # Second call: advanced_steps — short-circuit with a valid state
            # so _write_config_and_summary succeeds.
            if state is None:
                state = {}
            state.update(
                {
                    "provider": "none",
                    "model": "",
                    "dimension": 0,
                    "api_key": "",
                    "rerank_enabled": False,
                    "memory_dir": str(tmp_path / "memories"),
                    "db_path": str(tmp_path / ".memtomem" / "memtomem.db"),
                    "enable_auto_ns": False,
                    "default_ns": "default",
                    "top_k": 10,
                    "tokenizer": "unicode61",
                    "decay_enabled": False,
                    "mcp_choice": 3,
                    "settings_hooks": False,
                    "provider_dirs": [],
                    "provider_rules": [],
                }
            )
            return state

        monkeypatch.setattr(init_cmd, "run_steps", tracking_run_steps)

        runner = CliRunner()
        # Pick "4" = advanced. Picker raises _AdvancedSelected → outer catches
        # → advanced_steps runs (stubbed).
        result = runner.invoke(init_cmd.init, [], input="4\n")
        assert result.exit_code == 0, result.output

        assert len(recorded) == 2, f"expected 2 run_steps calls, got {len(recorded)}: {recorded}"
        combined, advanced = recorded
        # First call bundles picker with memory_dir and friends in ONE list —
        # this is the whole point of the fix.
        assert combined[0] == "_step_preset_picker"
        assert "_step_memory_dir" in combined
        # Second call is the 10-step advanced wizard.
        assert len(advanced) == 10
        assert "_step_embedding" in advanced
        assert "_step_preset_picker" not in advanced

    @pytest.mark.parametrize(
        "choice, expected_preset, expected_model, expected_tokenizer",
        [
            ("1", "minimal", "", "unicode61"),
            ("2", "english", "bge-small-en-v1.5", "unicode61"),
            ("3", "korean", "bge-m3", "kiwipiepy"),
        ],
    )
    def test_preset_picker_applies_preset_inline_on_non_advanced(
        self,
        monkeypatch: pytest.MonkeyPatch,
        choice: str,
        expected_preset: str,
        expected_model: str,
        expected_tokenizer: str,
    ) -> None:
        """Unit: picking any non-advanced preset must populate state with that
        preset's fields before ``_step_preset_picker`` returns — that's what
        lets the combined run_steps continue straight into ``_step_memory_dir``
        without a separate ``_apply_preset`` call. Parametrized across all
        three non-advanced options so a future preset-spec drift on any one
        breaks loudly and immediately."""
        import click

        from memtomem.cli.init_cmd import _step_preset_picker

        captured: dict[str, object] = {}

        @click.command()
        def harness() -> None:
            state: dict = {}
            _step_preset_picker(state)
            captured.update(state)

        from click.testing import CliRunner

        runner = CliRunner()
        result = runner.invoke(harness, input=f"{choice}\n")
        assert result.exit_code == 0, result.output
        assert captured.get("_preset_choice") == expected_preset
        assert captured.get("_preset_applied") == expected_preset
        assert captured.get("model") == expected_model
        assert captured.get("tokenizer") == expected_tokenizer

    def test_repeated_back_to_same_preset_is_idempotent(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """End-to-end: pick korean, "b", re-pick korean, proceed. The
        idempotent re-apply claim in the picker's docstring must hold for
        the same-preset case too (not only the different-preset case
        covered by test_back_at_memory_dir_returns_to_preset_picker).
        Regression guard: a future _apply_preset that toggles state (e.g.
        flips a bool) rather than assigning flat keys would fail here."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        memory_dir = tmp_path / "memories"
        result = CliRunner().invoke(
            init,
            [],
            # 3 (korean) → b → 3 (korean again) → dir → y → 3 (mcp skip)
            input=f"3\nb\n3\n{memory_dir}\ny\n3\n",
        )
        assert result.exit_code == 0, result.output

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        assert data["embedding"]["model"] == "bge-m3"
        assert data["search"]["tokenizer"] == "kiwipiepy"

    def test_repeated_back_cycle_through_multiple_presets(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """End-to-end: pick english → "b" → korean → "b" → minimal, then
        proceed. Exercises multiple back-cycles; the final preset must
        fully replace every previous partial application (provider flips
        onnx → onnx → none, rerank flips True → True → False, etc.). If
        any field from english/korean leaked past the final minimal pick
        this assertion would see it."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        memory_dir = tmp_path / "memories"
        result = CliRunner().invoke(
            init,
            [],
            # 2 (english) → b → 3 (korean) → b → 1 (minimal) → dir → y → 3
            input=f"2\nb\n3\nb\n1\n{memory_dir}\ny\n3\n",
        )
        assert result.exit_code == 0, result.output

        data = json.loads((tmp_path / ".memtomem" / "config.json").read_text(encoding="utf-8"))
        # Final pick was minimal: provider=none + no rerank + unicode61.
        assert data["embedding"]["provider"] == "none"
        assert data["embedding"]["model"] == ""
        assert data["search"]["tokenizer"] == "unicode61"
        # No stale rerank field leaked through (the #418 follow-up fix).
        assert "model" not in data.get("rerank", {}), data.get("rerank")

    def test_preset_picker_advanced_choice_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Unit: picking Advanced raises ``_AdvancedSelected`` without
        touching state (no ``_preset_choice`` write, no ``_apply_preset``
        call). The exception alone is what the caller relies on to break
        out of the combined ``run_steps`` and route to advanced_steps, so
        no state-based sentinel is needed."""
        import click

        from memtomem.cli.init_cmd import _AdvancedSelected, _step_preset_picker

        @click.command()
        def harness() -> None:
            state: dict = {"sentinel": "untouched"}
            try:
                _step_preset_picker(state)
            except _AdvancedSelected:
                click.echo(
                    f"raised preset_choice={state.get('_preset_choice')} "
                    f"applied={state.get('_preset_applied')} "
                    f"sentinel={state.get('sentinel')}"
                )
                return
            click.echo("did not raise")  # pragma: no cover — guarded by assertion

        from click.testing import CliRunner

        runner = CliRunner()
        result = runner.invoke(harness, input="4\n")  # "4" = advanced
        assert result.exit_code == 0, result.output
        # No dead write, no preset application — just the exception.
        assert "raised preset_choice=None applied=None sentinel=untouched" in result.output


class TestReinstallEmbeddingReconciliation:
    """mm init must detect and offer to fix embedding mismatches left by a
    previous install (e.g. provider=none → onnx switch leaves DB at dim=0)."""

    @staticmethod
    async def _seed_db(db_path: Path, provider: str, model: str, dimension: int) -> None:
        """Create a SQLite DB pre-initialised as if a prior install wrote it."""
        from memtomem.config import StorageConfig
        from memtomem.storage.sqlite_backend import SqliteBackend

        storage = SqliteBackend(
            StorageConfig(sqlite_path=db_path),
            dimension=dimension,
            embedding_provider=provider,
            embedding_model=model,
            strict_dim_check=False,
        )
        await storage.initialize()
        await storage.close()

    def test_fresh_db_is_noop(self, tmp_path: Path) -> None:
        """No DB at db_path → helper returns silently without prompting."""
        from memtomem.cli.init_cmd import _maybe_offer_embedding_reset

        state = _make_init_state(tmp_path)
        state.update({"provider": "onnx", "model": "bge-m3", "dimension": 1024})
        # db_path points at non-existent file
        assert not Path(state["db_path"]).exists()

        # Must not raise and must not emit a prompt (interactive=True would
        # otherwise block on stdin if the helper tried to prompt).
        _maybe_offer_embedding_reset(state, interactive=True)

    def test_matching_db_is_noop(self, tmp_path: Path) -> None:
        """DB already on the target provider/dim → no prompt."""
        import asyncio

        from memtomem.cli.init_cmd import _maybe_offer_embedding_reset

        db_path = tmp_path / "memtomem.db"
        asyncio.run(self._seed_db(db_path, "onnx", "bge-m3", 1024))

        state = _make_init_state(tmp_path)
        state.update(
            {
                "provider": "onnx",
                "model": "bge-m3",
                "dimension": 1024,
                "db_path": str(db_path),
            }
        )
        _maybe_offer_embedding_reset(state, interactive=True)

    def test_mismatch_non_interactive_warns_without_reset(
        self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Under -y, helper must print a recovery hint and leave DB untouched."""
        import asyncio

        from memtomem.cli.init_cmd import _maybe_offer_embedding_reset

        db_path = tmp_path / "memtomem.db"
        asyncio.run(self._seed_db(db_path, "none", "", 0))  # legacy NoopEmbedder

        state = _make_init_state(tmp_path)
        state.update(
            {
                "provider": "onnx",
                "model": "bge-m3",
                "dimension": 1024,
                "db_path": str(db_path),
            }
        )
        _maybe_offer_embedding_reset(state, interactive=False)

        out = capsys.readouterr().out
        assert "Existing DB detected" in out
        assert "mm embedding-reset --mode apply-current" in out

        # Stored meta is unchanged — non-interactive path must never reset.
        from memtomem.config import StorageConfig
        from memtomem.storage.sqlite_backend import SqliteBackend

        storage = SqliteBackend(
            StorageConfig(sqlite_path=db_path),
            dimension=0,
            embedding_provider="none",
            embedding_model="",
            strict_dim_check=False,
        )
        asyncio.run(storage.initialize())
        assert storage.stored_embedding_info["dimension"] == 0
        asyncio.run(storage.close())

    def test_mismatch_interactive_confirm_resets(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Interactive yes → chunks_vec recreated at new dimension."""
        import asyncio

        from memtomem.cli.init_cmd import _maybe_offer_embedding_reset

        db_path = tmp_path / "memtomem.db"
        asyncio.run(self._seed_db(db_path, "none", "", 0))

        state = _make_init_state(tmp_path)
        state.update(
            {
                "provider": "onnx",
                "model": "bge-m3",
                "dimension": 1024,
                "db_path": str(db_path),
            }
        )

        import click

        monkeypatch.setattr(click, "confirm", lambda *a, **kw: True)
        _maybe_offer_embedding_reset(state, interactive=True)

        out = capsys.readouterr().out
        assert "Vector index reset" in out

        from memtomem.config import StorageConfig
        from memtomem.storage.sqlite_backend import SqliteBackend

        storage = SqliteBackend(
            StorageConfig(sqlite_path=db_path),
            dimension=1024,
            embedding_provider="onnx",
            embedding_model="bge-m3",
            strict_dim_check=False,
        )
        asyncio.run(storage.initialize())
        # After reset, stored meta matches the new config — no mismatch.
        assert storage.embedding_mismatch is None
        assert storage.stored_embedding_info["dimension"] == 1024
        asyncio.run(storage.close())

    def test_mismatch_interactive_decline_skips(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Interactive no → DB untouched, helper points at recovery command."""
        import asyncio

        from memtomem.cli.init_cmd import _maybe_offer_embedding_reset

        db_path = tmp_path / "memtomem.db"
        asyncio.run(self._seed_db(db_path, "none", "", 0))

        state = _make_init_state(tmp_path)
        state.update(
            {
                "provider": "onnx",
                "model": "bge-m3",
                "dimension": 1024,
                "db_path": str(db_path),
            }
        )

        import click

        monkeypatch.setattr(click, "confirm", lambda *a, **kw: False)
        _maybe_offer_embedding_reset(state, interactive=True)

        out = capsys.readouterr().out
        assert "Skipped" in out
        assert "mm embedding-reset --mode apply-current" in out


class TestInitialSeedThreshold:
    """Opt-in initial-seed prompt at wizard end.

    Two-axis threshold (file count AND total bytes) classifies the dir as
    small vs large; **both** cases prompt (TTY only, default No), but the
    large case gets a provider-specific advisory first so the user sees
    an ETA expectation before committing. The seed itself runs via
    ``index_path_stream`` with a click progress bar so minutes-long
    embedder runs don't look hung — Ctrl-C cancels and is resumable via
    ``mm index`` (hash-dedup idempotent). Non-TTY (CI / piped stdin)
    always skips silently so scripted ``mm init -y`` pipelines keep
    passing."""

    @staticmethod
    def _write_md(dir_: Path, name: str, body: str) -> Path:
        f = dir_ / name
        # ``newline=""`` disables platform newline translation so byte-count
        # assertions (e.g. ``len(body) == _collect_seed_scale(...)`` total)
        # stay correct on Windows where ``\n`` would otherwise become ``\r\n``.
        f.write_text(body, encoding="utf-8", newline="")
        return f

    def test_collect_seed_scale_counts_md_only_and_sums_bytes(self, tmp_path: Path) -> None:
        """`.md` files count toward both axes; `.json` / other extensions
        don't (threshold is tuned for markdown embedding cost)."""
        from memtomem.cli.init_cmd import _collect_seed_scale

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()
        self._write_md(memory_dir, "a.md", "hello world\n")
        self._write_md(memory_dir, "b.md", "x" * 100)
        (memory_dir / "ignored.json").write_text('{"k": 1}', encoding="utf-8")

        count, total = _collect_seed_scale(memory_dir)
        assert count == 2
        assert total == len("hello world\n") + 100

    def test_collect_seed_scale_missing_dir_returns_zero(self, tmp_path: Path) -> None:
        """Non-existent memory_dir → (0, 0). The caller uses count==0 to
        skip silently — avoids the wizard printing "no files found" noise
        right after it just printed `Memory: <dir>`."""
        from memtomem.cli.init_cmd import _collect_seed_scale

        assert _collect_seed_scale(tmp_path / "does-not-exist") == (0, 0)

    def test_format_size_boundaries(self) -> None:
        """Sub-KB totals display as ``<1 KB`` not ``0 KB``. Regression
        guard: 15 tiny memo files triggered the large-by-count branch
        and showed "(0 KB)" to the user, who'd reasonably think it was
        a display bug. Also pin the MB boundary (>= 1024 KB) to catch
        off-by-one rounding."""
        from memtomem.cli.init_cmd import _format_size

        assert _format_size(0) == "0 bytes"
        assert _format_size(500) == "<1 KB"
        assert _format_size(1024) == "1 KB"
        assert _format_size(65 * 1024) == "65 KB"
        assert _format_size(1024 * 1024) == "1 MB"
        assert _format_size(12 * 1024 * 1024) == "12 MB"

    def test_provider_seed_hint_variants(self) -> None:
        """Provider-specific advisory strings. Pins the four known
        providers so a silent change (e.g. dropping ``openai``) fails a
        test instead of landing a wrong ETA in production. Unknown
        providers return None so the caller falls back to a generic
        progress-bar message rather than fabricating a number."""
        from memtomem.cli.init_cmd import _provider_seed_hint

        assert "bge-m3" in (_provider_seed_hint("onnx") or "")
        assert "Ollama" in (_provider_seed_hint("ollama") or "")
        assert "OpenAI" in (_provider_seed_hint("openai") or "")
        assert "BM25" in (_provider_seed_hint("none") or "")
        assert _provider_seed_hint("") is None
        assert _provider_seed_hint("novel-provider") is None

    def test_maybe_seed_large_by_count_prompts_with_advisory(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """11 files (>_SEED_MAX_FILES) → advisory + prompt, default No.
        Regression guard: pre-#375-followup version skipped large cases
        silently; now users always get a choice (with a visible progress
        bar under the hood) so "파일 많을 때 수동만 가능" UX gap is closed."""
        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()
        for i in range(11):
            self._write_md(memory_dir, f"m{i}.md", f"memo {i}")

        monkeypatch.setattr("sys.stdin.isatty", lambda: True)
        confirm_fired = {"yes": False}
        monkeypatch.setattr(
            "click.confirm",
            lambda *a, **kw: confirm_fired.__setitem__("yes", True) or False,
        )

        state = {"provider": "onnx"}
        assert init_cmd._maybe_seed_initial_index([memory_dir], state) is False
        assert confirm_fired["yes"] is True  # prompt DID fire this time
        out = capsys.readouterr().out
        assert "11 file(s)" in out
        assert "bge-m3" in out  # provider-specific advisory
        assert "Ctrl-C" in out

    def test_maybe_seed_large_by_bytes_also_prompts(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """5 files but total > 64 KB → same path (large branch prompts).
        Exercises the AND-semantics on the bytes axis, not just count."""
        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()
        for i in range(5):
            self._write_md(memory_dir, f"big{i}.md", "x" * 20_000)  # 100 KB total

        monkeypatch.setattr("sys.stdin.isatty", lambda: True)
        confirm_fired = {"yes": False}
        monkeypatch.setattr(
            "click.confirm",
            lambda *a, **kw: confirm_fired.__setitem__("yes", True) or False,
        )

        state = {"provider": "ollama"}
        assert init_cmd._maybe_seed_initial_index([memory_dir], state) is False
        assert confirm_fired["yes"] is True
        out = capsys.readouterr().out
        assert "5 file(s)" in out
        assert "Ollama" in out  # provider advisory reflects state["provider"]

    def test_maybe_seed_large_accept_invokes_seed(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Large + Yes → _seed_with_progress fires. Stubs the seeder so
        the test doesn't need a working embedder; the seeder failure
        path is exercised separately by
        test_seed_with_progress_failure_is_graceful."""
        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()
        for i in range(11):
            self._write_md(memory_dir, f"m{i}.md", f"memo {i}")

        monkeypatch.setattr("sys.stdin.isatty", lambda: True)
        monkeypatch.setattr("click.confirm", lambda *a, **kw: True)

        seen: dict[str, list[Path]] = {}
        monkeypatch.setattr(
            init_cmd,
            "_seed_with_progress",
            lambda paths: seen.__setitem__("paths", list(paths)) or True,
        )

        assert init_cmd._maybe_seed_initial_index([memory_dir], {"provider": "none"}) is True
        assert seen["paths"] == [memory_dir]

    def test_maybe_seed_non_tty_silent_skip(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Small dir + no TTY → silent skip. Prompt-on-non-TTY would
        raise click.Abort and break `mm init -y </dev/null` pipelines
        (`feedback_click_prompt_needs_isatty_gate.md`)."""
        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()
        self._write_md(memory_dir, "only.md", "short")

        monkeypatch.setattr("sys.stdin.isatty", lambda: False)

        confirm_fired = {"yes": False}
        monkeypatch.setattr(
            "click.confirm",
            lambda *a, **kw: confirm_fired.__setitem__("yes", True) or True,
        )

        assert init_cmd._maybe_seed_initial_index([memory_dir], {"provider": "none"}) is False
        assert confirm_fired["yes"] is False
        assert capsys.readouterr().out == ""

    def test_maybe_seed_large_non_tty_silent_skip(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Large dir + no TTY → silent skip too. Previously large case
        emitted a cyan hint even without a TTY; now that large-case
        prompts, consistent skip semantics apply — the Next-steps
        ``mm index`` line already tells non-interactive users what to
        run, no need to duplicate."""
        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()
        for i in range(11):
            self._write_md(memory_dir, f"m{i}.md", f"memo {i}")

        monkeypatch.setattr("sys.stdin.isatty", lambda: False)

        assert init_cmd._maybe_seed_initial_index([memory_dir], {"provider": "onnx"}) is False
        assert capsys.readouterr().out == ""

    def test_maybe_seed_prompt_decline(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Small + TTY + No → no seed call. Verifies the default-No
        path (Enter-is-Skip) doesn't accidentally seed — the wizard's
        other prompts mostly use default-Yes, so the contrast is worth
        pinning."""
        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()
        self._write_md(memory_dir, "only.md", "short")

        monkeypatch.setattr("sys.stdin.isatty", lambda: True)
        monkeypatch.setattr("click.confirm", lambda *a, **kw: False)

        seed_called = {"yes": False}
        monkeypatch.setattr(
            init_cmd,
            "_seed_with_progress",
            lambda paths: seed_called.__setitem__("yes", True) or True,
        )

        assert init_cmd._maybe_seed_initial_index([memory_dir], {"provider": "none"}) is False
        assert seed_called["yes"] is False

    def test_maybe_seed_prompt_accept_runs_seed(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Small + TTY + Yes → _seed_with_progress invoked, return value
        propagates."""
        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()
        self._write_md(memory_dir, "only.md", "short")

        monkeypatch.setattr("sys.stdin.isatty", lambda: True)
        monkeypatch.setattr("click.confirm", lambda *a, **kw: True)

        seen: dict[str, list[Path]] = {}

        def _stub_seed(paths: list[Path]) -> bool:
            seen["paths"] = list(paths)
            return True

        monkeypatch.setattr(init_cmd, "_seed_with_progress", _stub_seed)

        assert init_cmd._maybe_seed_initial_index([memory_dir], {"provider": "none"}) is True
        assert seen["paths"] == [memory_dir]

    def test_seed_with_progress_failure_is_graceful(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """If ``cli_components`` / ``index_path_stream`` raises, return
        False with a yellow warning + manual-rerun hint. Wizard must not
        abort — config.json is already written and the seed is
        best-effort."""
        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()

        from contextlib import asynccontextmanager

        @asynccontextmanager  # type: ignore[misc]
        async def _broken_components() -> object:  # pragma: no cover - generator
            raise RuntimeError("embedder unavailable")
            yield  # unreachable

        monkeypatch.setattr("memtomem.cli._bootstrap.cli_components", _broken_components)

        assert init_cmd._seed_with_progress([memory_dir]) is False
        out = capsys.readouterr().out
        assert "Skipped initial seed" in out
        assert "embedder unavailable" in out
        assert f"mm index {memory_dir}" in out

    def test_seed_with_progress_zero_chunks_emits_warning(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Stream processes files but lands 0 chunks → yellow warning,
        return False. Known trigger: provider=none (NoopEmbedder dim=0)
        leaves ``chunks_vec`` vtable uncreated, ``upsert_chunks`` rolls
        back every insert. Wizard previously printed green "Seeded N
        file(s), 0 new chunk(s), 0 unchanged" — misleading success.
        Regression guard: any future silent per-file failure that
        aggregates to zero counters should surface as yellow, not green,
        and skip the Next-steps step-1 annotation."""
        from contextlib import asynccontextmanager

        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()

        class _FakeEngine:
            async def index_path_stream(self, path, recursive=True, force=False, namespace=None):
                yield {"type": "discovery", "files_total": 1}
                yield {
                    "type": "progress",
                    "file": str(memory_dir / "a.md"),
                    "files_done": 1,
                    "files_total": 1,
                    "indexed": 0,
                    "skipped": 0,
                }
                yield {
                    "type": "complete",
                    "total_files": 1,
                    "total_chunks": 0,
                    "indexed_chunks": 0,
                    "skipped_chunks": 0,
                    "deleted_chunks": 0,
                    "duration_ms": 1.0,
                }

        class _FakeComp:
            index_engine = _FakeEngine()

        @asynccontextmanager  # type: ignore[misc]
        async def _fake_components():
            yield _FakeComp()

        monkeypatch.setattr("memtomem.cli._bootstrap.cli_components", _fake_components)

        assert init_cmd._seed_with_progress([memory_dir]) is False
        out = capsys.readouterr().out
        assert "0 chunks were indexed" in out
        assert "embedding-reset" in out
        # Regression: must NOT print green "Seeded initial index" success line.
        assert "Seeded initial index" not in out

    def test_seed_with_progress_happy_path_suppresses_zero_chunks_warning(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Happy-path pin: when the stream reports ``indexed > 0`` (the
        expected outcome of fresh ``--provider none`` post-fix), the yellow
        "0 chunks were indexed" warning must NOT fire. Guards against the
        warning condition widening over time — if someone later tightens the
        threshold (e.g. ``indexed < 2``), this test catches it before users
        see spurious warnings on valid BM25-only installs."""
        from contextlib import asynccontextmanager

        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()

        class _FakeEngine:
            async def index_path_stream(self, path, recursive=True, force=False, namespace=None):
                yield {"type": "discovery", "files_total": 1}
                yield {
                    "type": "progress",
                    "file": str(memory_dir / "a.md"),
                    "files_done": 1,
                    "files_total": 1,
                    "indexed": 3,
                    "skipped": 0,
                }
                yield {
                    "type": "complete",
                    "total_files": 1,
                    "total_chunks": 3,
                    "indexed_chunks": 3,
                    "skipped_chunks": 0,
                    "deleted_chunks": 0,
                    "duration_ms": 1.0,
                }

        class _FakeComp:
            index_engine = _FakeEngine()

        @asynccontextmanager  # type: ignore[misc]
        async def _fake_components():
            yield _FakeComp()

        monkeypatch.setattr("memtomem.cli._bootstrap.cli_components", _fake_components)

        assert init_cmd._seed_with_progress([memory_dir]) is True
        out = capsys.readouterr().out
        assert "Seeded initial index" in out
        assert "3 new chunk(s)" in out
        # Regression: yellow warning must NOT fire on happy path.
        assert "0 chunks were indexed" not in out
        assert "embedding-reset" not in out

    def test_seed_with_progress_keyboard_interrupt_is_graceful(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Ctrl-C during the stream → yellow resume hint, not a traceback.
        This is the primary mitigation for "seeding looks hung" UX —
        users must have a visible escape hatch that lands them back in
        their shell cleanly. ``mm index`` idempotency (content-hash
        dedup) makes resuming safe."""
        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()

        from contextlib import asynccontextmanager

        @asynccontextmanager  # type: ignore[misc]
        async def _ctrl_c_components() -> object:  # pragma: no cover - generator
            raise KeyboardInterrupt
            yield  # unreachable

        monkeypatch.setattr("memtomem.cli._bootstrap.cli_components", _ctrl_c_components)

        assert init_cmd._seed_with_progress([memory_dir]) is False
        out = capsys.readouterr().out
        assert "Cancelled" in out
        assert f"mm index {memory_dir}" in out

    # ------------------------------------------------------------------
    # Multi-path (primary memory_dir + provider_dirs) — issue #360 f/u
    # ------------------------------------------------------------------

    def test_maybe_seed_empty_primary_plus_provider_dir_prompts(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Primary memory_dir empty + one provider dir with files → prompt
        STILL fires with aggregated counts. Regression guard for the core
        UX bug this landed: ``mm init --preset korean`` registers ~28
        provider dirs but the prior code scanned only the primary dir,
        which is typically the empty ``~/memories``, so the seed silently
        skipped every real-world install."""
        from memtomem.cli import init_cmd

        primary = tmp_path / "memories"
        primary.mkdir()  # empty
        provider = tmp_path / "claude-memory"
        provider.mkdir()
        for i in range(3):
            self._write_md(provider, f"m{i}.md", f"memo {i}")

        monkeypatch.setattr("sys.stdin.isatty", lambda: True)
        prompts: list[str] = []

        def _capture(text, *args, **kwargs):
            prompts.append(text)
            return False

        monkeypatch.setattr("click.confirm", _capture)

        assert (
            init_cmd._maybe_seed_initial_index([primary, provider], {"provider": "none"}) is False
        )
        # Small-case prompt text passes through click.confirm's first arg —
        # capsys wouldn't catch it. Aggregated counts: 3 from provider + 0
        # from empty primary = 3; phrasing is "across 2 memory dirs".
        assert len(prompts) == 1
        assert "3 existing file(s)" in prompts[0]
        assert "across 2 memory dirs" in prompts[0]

    def test_maybe_seed_multi_path_skips_nonexistent(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Non-existent paths are dropped before the scan. Protects against
        stale provider_dirs entries (user moved a folder between init
        runs). If the union reduces to zero existing paths, we return
        False silently — no "no files found" noise."""
        from memtomem.cli import init_cmd

        existing = tmp_path / "have"
        existing.mkdir()
        self._write_md(existing, "a.md", "hi")
        missing = tmp_path / "gone"  # never created

        monkeypatch.setattr("sys.stdin.isatty", lambda: True)
        monkeypatch.setattr("click.confirm", lambda *a, **kw: True)

        passed: dict[str, list[Path]] = {}
        monkeypatch.setattr(
            init_cmd,
            "_seed_with_progress",
            lambda paths: passed.__setitem__("paths", list(paths)) or True,
        )

        assert init_cmd._maybe_seed_initial_index([missing, existing], {"provider": "none"}) is True
        assert passed["paths"] == [existing]

    def test_maybe_seed_large_multi_path_resume_hint_mentions_web(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Large-case advisory for multi-path runs points at the Web UI
        Reindex All, not ``mm index <dir>``. ``mm index`` is single-path
        only, so suggesting it for a 28-dir union would leave 27 dirs
        unindexed. Dev must match ``_seed_with_progress`` resume hint
        (same cross-affordance)."""
        from memtomem.cli import init_cmd

        primary = tmp_path / "memories"
        primary.mkdir()
        provider = tmp_path / "claude-memory"
        provider.mkdir()
        for i in range(12):  # > _SEED_MAX_FILES
            self._write_md(provider, f"m{i}.md", f"memo {i}")

        monkeypatch.setattr("sys.stdin.isatty", lambda: True)
        monkeypatch.setattr("click.confirm", lambda *a, **kw: False)

        init_cmd._maybe_seed_initial_index([primary, provider], {"provider": "onnx"})
        out = capsys.readouterr().out
        assert "12 file(s)" in out
        assert "across 2 memory dirs" in out
        # Multi-path resume hint goes to Web UI, not `mm index <dir>`.
        assert "Reindex All" in out
        assert "`mm index <dir>`" not in out

    def test_seed_with_progress_multi_path_aggregates_counters(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Two paths streamed serially → complete counters sum into one
        green summary line. Pins the aggregation so a future refactor
        that regresses to "report only the last path's counters" fails
        instead of silently under-reporting."""
        from contextlib import asynccontextmanager

        from memtomem.cli import init_cmd

        dir_a = tmp_path / "a"
        dir_a.mkdir()
        dir_b = tmp_path / "b"
        dir_b.mkdir()
        # Real source files for the on-disk roots — the fake engine returns
        # synthetic event counts, but the directories still need to exist
        # and contain something so any future assertion that walks the tree
        # has something to find. (No longer load-bearing for the bar length
        # since #743 — that comes from the engine's discovery event.)
        self._write_md(dir_a, "a.md", "hello")
        self._write_md(dir_b, "b.md", "world")

        path_counts = {
            str(dir_a): {"total_files": 2, "indexed_chunks": 5, "skipped_chunks": 1},
            str(dir_b): {"total_files": 3, "indexed_chunks": 7, "skipped_chunks": 2},
        }

        class _FakeEngine:
            async def index_path_stream(self, path, recursive=True, force=False, namespace=None):
                key = str(path)
                counts = path_counts[key]
                yield {"type": "discovery", "files_total": counts["total_files"]}
                yield {
                    "type": "progress",
                    "file": f"{key}/file.md",
                    "files_done": 1,
                    "files_total": 1,
                    "indexed": counts["indexed_chunks"],
                    "skipped": counts["skipped_chunks"],
                }
                yield {
                    "type": "complete",
                    "total_files": counts["total_files"],
                    "total_chunks": counts["indexed_chunks"],
                    "indexed_chunks": counts["indexed_chunks"],
                    "skipped_chunks": counts["skipped_chunks"],
                    "deleted_chunks": 0,
                    "duration_ms": 1.0,
                }

        class _FakeComp:
            index_engine = _FakeEngine()

        @asynccontextmanager  # type: ignore[misc]
        async def _fake_components():
            yield _FakeComp()

        monkeypatch.setattr("memtomem.cli._bootstrap.cli_components", _fake_components)

        assert init_cmd._seed_with_progress([dir_a, dir_b]) is True
        out = capsys.readouterr().out
        # 2 + 3 = 5 files, 5 + 7 = 12 new chunks, 1 + 2 = 3 unchanged.
        assert "5 file(s)" in out
        assert "12 new chunk(s)" in out
        assert "3 unchanged" in out

    def test_seed_with_progress_keyboard_interrupt_multi_path_web_hint(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Ctrl-C during multi-path seed → resume hint points at
        ``mm web``, not ``mm index <dir>``. Single-path hint continues to
        use ``mm index`` (tested above); this pins the branch."""
        from contextlib import asynccontextmanager

        from memtomem.cli import init_cmd

        dir_a = tmp_path / "a"
        dir_a.mkdir()
        dir_b = tmp_path / "b"
        dir_b.mkdir()

        @asynccontextmanager  # type: ignore[misc]
        async def _ctrl_c_components() -> object:  # pragma: no cover - generator
            raise KeyboardInterrupt
            yield  # unreachable

        monkeypatch.setattr("memtomem.cli._bootstrap.cli_components", _ctrl_c_components)

        assert init_cmd._seed_with_progress([dir_a, dir_b]) is False
        out = capsys.readouterr().out
        assert "Cancelled" in out
        assert "Reindex All" in out
        # Regression: must NOT print the single-dir `mm index <dir>` hint.
        assert f"mm index {dir_a}" not in out
        assert f"mm index {dir_b}" not in out

    # ------------------------------------------------------------------
    # chunk_progress sub-label refresh (issue #655) — wizard surfaces
    # per-chunk progress so multi-minute embedder runs don't look hung.
    # ------------------------------------------------------------------

    def test_seed_with_progress_chunk_events_do_not_advance_bar(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """``chunk_progress`` events refresh the progress bar's sub-label
        without advancing ``pos``. Bar length is in **file units**, so the
        final ``pos`` after a 1-file run with N intermediate chunk events
        must equal 1 (the file count), not 1 + N. Regression guard for the
        "label refresh, no advance" design — Option 1 from issue #655."""
        from contextlib import asynccontextmanager

        import click

        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()
        self._write_md(memory_dir, "big.md", "lots of chunks")

        captured: dict[str, object] = {}

        class _FakeEngine:
            async def index_path_stream(self, path, recursive=True, force=False, namespace=None):
                yield {"type": "discovery", "files_total": 1}
                file = str(memory_dir / "big.md")
                # Server only emits chunk_progress when the file exceeds the
                # ``progress_threshold`` (default 32). Mimic a 50-chunk file.
                for done in (10, 25, 40, 50):
                    yield {
                        "type": "chunk_progress",
                        "file": file,
                        "chunks_done": done,
                        "chunks_total": 50,
                        "files_done": 0,
                        "files_total": 1,
                    }
                yield {
                    "type": "progress",
                    "file": file,
                    "files_done": 1,
                    "files_total": 1,
                    "indexed": 50,
                    "skipped": 0,
                }
                yield {
                    "type": "complete",
                    "total_files": 1,
                    "total_chunks": 50,
                    "indexed_chunks": 50,
                    "skipped_chunks": 0,
                    "deleted_chunks": 0,
                    "duration_ms": 1.0,
                }

        class _FakeComp:
            index_engine = _FakeEngine()

        @asynccontextmanager  # type: ignore[misc]
        async def _fake_components():
            yield _FakeComp()

        monkeypatch.setattr("memtomem.cli._bootstrap.cli_components", _fake_components)

        # Wrap click.progressbar so we can read the bar's final state after
        # ``__exit__`` is called inside _close_bar.
        real_progressbar = click.progressbar

        def _spy_progressbar(*args, **kwargs):
            bar = real_progressbar(*args, **kwargs)
            captured["bar"] = bar
            return bar

        monkeypatch.setattr("memtomem.cli.init_cmd.click.progressbar", _spy_progressbar)

        assert init_cmd._seed_with_progress([memory_dir]) is True
        bar = captured["bar"]
        # 4 chunk_progress events + 1 progress event → only the progress
        # event advances. Chunk events refresh the label only.
        assert bar.pos == 1, f"expected pos==1 (file count), got {bar.pos}"

    def test_seed_with_progress_no_chunk_events_unchanged(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Pre-existing behavior pin: a stream with no ``chunk_progress``
        events behaves identically to today — final ``pos`` equals the file
        count, label is the file basename. Small files (under
        ``progress_threshold``) take this path; the wizard must stay quiet
        for them, matching the web Index tab."""
        from contextlib import asynccontextmanager

        import click

        from memtomem.cli import init_cmd

        memory_dir = tmp_path / "memories"
        memory_dir.mkdir()
        self._write_md(memory_dir, "a.md", "small")
        self._write_md(memory_dir, "b.md", "small too")

        captured: dict[str, object] = {}

        class _FakeEngine:
            async def index_path_stream(self, path, recursive=True, force=False, namespace=None):
                yield {"type": "discovery", "files_total": 2}
                for name in ("a.md", "b.md"):
                    yield {
                        "type": "progress",
                        "file": str(memory_dir / name),
                        "files_done": 1,
                        "files_total": 2,
                        "indexed": 1,
                        "skipped": 0,
                    }
                yield {
                    "type": "complete",
                    "total_files": 2,
                    "total_chunks": 2,
                    "indexed_chunks": 2,
                    "skipped_chunks": 0,
                    "deleted_chunks": 0,
                    "duration_ms": 1.0,
                }

        class _FakeComp:
            index_engine = _FakeEngine()

        @asynccontextmanager  # type: ignore[misc]
        async def _fake_components():
            yield _FakeComp()

        monkeypatch.setattr("memtomem.cli._bootstrap.cli_components", _fake_components)

        real_progressbar = click.progressbar

        def _spy_progressbar(*args, **kwargs):
            bar = real_progressbar(*args, **kwargs)
            captured["bar"] = bar
            return bar

        monkeypatch.setattr("memtomem.cli.init_cmd.click.progressbar", _spy_progressbar)

        assert init_cmd._seed_with_progress([memory_dir]) is True
        bar = captured["bar"]
        # 2 progress events advance the bar by 1 each → pos == 2 (file count).
        assert bar.pos == 2, f"expected pos==2 (file count), got {bar.pos}"

    # ------------------------------------------------------------------
    # Next-steps step 1 hint — seeded / single-dir / multi-dir branches
    # ------------------------------------------------------------------

    def test_next_steps_multi_dir_unseeded_points_at_web_reindex(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """When provider_dirs are present and the seed is declined/skipped,
        step 1 must point at ``mm web`` → Sources → Reindex All.
        ``mm index ~/memories`` would only cover the primary dir — leaving
        the 28 provider dirs unindexed, which is exactly the UX bug this
        PR fixes."""
        from click import unstyle

        from memtomem.cli import init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(init_cmd, "_maybe_seed_initial_index", lambda paths, state: False)

        state = _make_init_state(tmp_path)
        state["provider_dirs"] = [
            str(tmp_path / "claude-memory"),
            str(tmp_path / "claude-plans"),
        ]
        state["_profile"] = _make_test_profile(tmp_path, kind="source")

        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert "Next steps:" in out
        # 3 = primary memory_dir + 2 provider_dirs. Dedup would not fire here
        # because the three paths are distinct.
        assert "Reindex All to index 3 memory_dirs" in out
        # Regression: single-dir hint must NOT fire for multi-dir unseeded.
        assert f"mm index {state['memory_dir']}\n" not in out
        # Step 1 already opens `mm web`, so the legacy step 3 ("3. mm web
        # (browse...)") is folded into step 1 to avoid printing the same
        # command on two lines (UX cleanup, 2026-04-29). Pin both halves —
        # the consolidated wording in step 1, and the absence of a
        # standalone step-3 line.
        assert "then browse & manage your memories" in out
        # The 4-space indented "3. " step-line must be gone in this branch.
        assert "    3. " not in out

    def test_next_steps_single_dir_unseeded_keeps_mm_index_hint(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """No provider_dirs + seed declined → legacy ``mm index <primary>``
        hint still fires. Guards against the multi-dir branch over-firing
        and hijacking the single-dir UX."""
        from click import unstyle

        from memtomem.cli import init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(init_cmd, "_maybe_seed_initial_index", lambda paths, state: False)

        state = _make_init_state(tmp_path)
        state["provider_dirs"] = []
        state["_profile"] = _make_test_profile(tmp_path, kind="source")

        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert f"mm index {state['memory_dir']}" in out
        assert "Reindex All" not in out
        assert "already seeded" not in out
        # In single-dir (or seeded) branches step 1 is `mm index`, not
        # `mm web`, so the standalone step-3 `mm web (browse...)` line
        # must still fire — the consolidation only applies to
        # multi-dir-unseeded.
        assert "    3. " in out
        assert "mm web  (browse & manage your memories)" in out

    def test_next_steps_seeded_annotates_mm_index_hint(
        self,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """When the seed ran inline, step 1 keeps ``mm index <primary>`` but
        annotates "already seeded — re-run only if you add files" so users
        don't double-index. Pins the annotation wording — changing it
        silently would regress the UX clarity."""
        from click import unstyle

        from memtomem.cli import init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(init_cmd, "_maybe_seed_initial_index", lambda paths, state: True)

        state = _make_init_state(tmp_path)
        # provider_dirs populated — seeded=True path wins over multi-dir
        # branch (already-seeded annotation applies to primary regardless).
        state["provider_dirs"] = [str(tmp_path / "claude-memory")]
        state["_profile"] = _make_test_profile(tmp_path, kind="source")

        init_cmd._write_config_and_summary(state, tmp_path)

        out = unstyle(capsys.readouterr().out)
        assert f"mm index {state['memory_dir']}" in out
        assert "already seeded" in out
        assert "Reindex All" not in out


class TestNonInteractiveExtrasValidation:
    """Issue #396: ``mm init -y`` must refuse to write a config when the
    requested ``--provider`` / ``--tokenizer`` needs an extra that is not
    importable AND has no working runtime fallback — e.g. ``onnx`` (fastembed),
    whose absence leaves the embedder at dimension 0. (``kiwipiepy`` falls back
    to unicode61, so it warns rather than aborts.) The ``ollama`` / ``openai``
    HTTP providers need NO extra — their embedders run on httpx — so they must
    NOT be gated on the PyPI clients."""

    def test_collect_missing_extras_ollama_needs_no_client_package(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from memtomem.cli import init_cmd

        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "ollama" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)
        state = {"provider": "ollama", "rerank_enabled": False}
        # Ollama embedder uses httpx, not the ``ollama`` PyPI client.
        assert init_cmd._collect_missing_extras(state) == []

    def test_collect_missing_extras_openai_needs_no_client_package(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from memtomem.cli import init_cmd

        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "openai" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)
        state = {"provider": "openai", "rerank_enabled": False}
        # OpenAI embedder uses httpx + an API key, not the ``openai`` PyPI client.
        assert init_cmd._collect_missing_extras(state) == []

    def test_collect_missing_extras_kiwipiepy_when_tokenizer_chose(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from memtomem.cli import init_cmd

        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "kiwipiepy" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)
        state = {"provider": "none", "tokenizer": "kiwipiepy", "rerank_enabled": False}
        assert init_cmd._collect_missing_extras(state) == ["korean"]

    def test_yes_flag_provider_onnx_missing_fastembed_exits_non_zero(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from click.testing import CliRunner

        from memtomem.cli import cli, init_cmd

        set_home(monkeypatch, tmp_path)
        # Force the runtime profile to PyPI so the in-process find_spec probe
        # is authoritative (no workspace-python subprocess to consider).
        monkeypatch.setattr(init_cmd, "_runtime_profile", lambda: _make_test_profile(kind="pypi"))
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "fastembed" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)

        result = CliRunner().invoke(
            cli,
            [
                "init",
                "-y",
                "--provider",
                "onnx",
                "--model",
                "bge-small-en-v1.5",
                "--mcp",
                "skip",
            ],
        )

        assert result.exit_code != 0, result.output
        assert "Missing required extras" in result.output
        assert "onnx" in result.output
        # Hint should be install-type-aware — pypi → uv tool install --reinstall.
        assert "uv tool install --reinstall" in result.output
        # Config must not be written.
        assert not (tmp_path / ".memtomem" / "config.json").exists()

    def test_yes_flag_tokenizer_kiwipiepy_missing_exits_non_zero(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from click.testing import CliRunner

        from memtomem.cli import cli, init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(init_cmd, "_runtime_profile", lambda: _make_test_profile(kind="pypi"))
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "kiwipiepy" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)

        result = CliRunner().invoke(
            cli, ["init", "-y", "--tokenizer", "kiwipiepy", "--mcp", "skip"]
        )

        assert result.exit_code != 0, result.output
        assert "Missing required extras" in result.output
        assert "korean" in result.output
        assert not (tmp_path / ".memtomem" / "config.json").exists()

    def test_yes_flag_provider_ollama_missing_client_still_writes_config(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """The Ollama embedder runs on httpx, not the ``ollama`` PyPI client, so
        a missing client must NOT block ``-y --provider ollama`` — the config is
        written and embedding runs once the server is up. (Previously this
        aborted with "Missing required extras", a false failure for valid
        configs.)"""
        from click.testing import CliRunner

        from memtomem.cli import cli, init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(init_cmd, "_runtime_profile", lambda: _make_test_profile(kind="pypi"))
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "ollama" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)

        result = CliRunner().invoke(
            cli,
            [
                "init",
                "-y",
                "--provider",
                "ollama",
                "--model",
                "nomic-embed-text",
                "--mcp",
                "skip",
            ],
        )

        assert result.exit_code == 0, result.output
        assert "Missing required extras" not in result.output
        assert (tmp_path / ".memtomem" / "config.json").exists()

    def test_yes_flag_web_only_missing_still_writes_config(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """web is deliberately excluded from the required-extras set —
        scripted runs with ``--mcp skip`` don't plan to run ``mm web``,
        and the runtime ``mm web`` error is already loud. A config write
        must proceed in this case."""
        from click.testing import CliRunner

        from memtomem.cli import cli, init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(init_cmd, "_runtime_profile", lambda: _make_test_profile(kind="pypi"))
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name in ("fastapi", "uvicorn") else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: "fastapi", raising=False)

        result = CliRunner().invoke(cli, ["init", "-y", "--provider", "none", "--mcp", "skip"])

        assert result.exit_code == 0, result.output
        assert (tmp_path / ".memtomem" / "config.json").exists()

    def test_yes_flag_all_extras_present_writes_config(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        from click.testing import CliRunner

        from memtomem.cli import cli, init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(init_cmd, "_runtime_profile", lambda: _make_test_profile(kind="pypi"))
        monkeypatch.setattr("importlib.util.find_spec", lambda name: object())
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)

        result = CliRunner().invoke(
            cli,
            ["init", "-y", "--provider", "onnx", "--model", "bge-m3", "--mcp", "skip"],
        )

        assert result.exit_code == 0, result.output
        assert (tmp_path / ".memtomem" / "config.json").exists()

    def test_yes_flag_provider_openai_missing_client_still_writes_config(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """The OpenAI embedder runs on httpx + an API key, not the ``openai``
        PyPI client, so a missing client must NOT block ``-y --provider openai``
        when a key is supplied — the config is written. (Previously this aborted
        with "Missing required extras" even with ``--api-key`` present, a false
        failure for valid configs.) ``--api-key`` plumbing sits alongside in
        ``_override_from_flags``; exercising the full CLI path proves the key
        handling still runs."""
        from click.testing import CliRunner

        from memtomem.cli import cli, init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(init_cmd, "_runtime_profile", lambda: _make_test_profile(kind="pypi"))
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "openai" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)

        result = CliRunner().invoke(
            cli,
            [
                "init",
                "-y",
                "--provider",
                "openai",
                "--model",
                "text-embedding-3-small",
                "--api-key",
                "sk-fake-test-key",
                "--mcp",
                "skip",
            ],
        )

        assert result.exit_code == 0, result.output
        assert "Missing required extras" not in result.output
        assert (tmp_path / ".memtomem" / "config.json").exists()

    def test_yes_flag_multi_extra_missing_reports_both(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Both ``--provider onnx`` + ``--tokenizer kiwipiepy`` missing at
        once. The error must name both so the user knows to install both
        or drop both, not just the first one encountered."""
        from click.testing import CliRunner

        from memtomem.cli import cli, init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(init_cmd, "_runtime_profile", lambda: _make_test_profile(kind="pypi"))
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name in ("fastembed", "kiwipiepy") else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)

        result = CliRunner().invoke(
            cli,
            [
                "init",
                "-y",
                "--provider",
                "onnx",
                "--model",
                "bge-m3",
                "--tokenizer",
                "kiwipiepy",
                "--mcp",
                "skip",
            ],
        )

        assert result.exit_code != 0, result.output
        assert "Missing required extras" in result.output
        # Both extras must be named, not just the first one encountered.
        assert "onnx" in result.output
        assert "korean" in result.output
        # Multi-extra hint collapses to [all] per _extra_install_hint policy.
        assert "memtomem[all]" in result.output
        assert not (tmp_path / ".memtomem" / "config.json").exists()

    def test_yes_flag_refused_leaves_memory_dir_untouched(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Gate must run BEFORE ``memory_path.mkdir``. An earlier shape
        created ``~/memories`` before validating extras, leaving directory
        debris on refused runs."""
        from click.testing import CliRunner

        from memtomem.cli import cli, init_cmd

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr(init_cmd, "_runtime_profile", lambda: _make_test_profile(kind="pypi"))
        monkeypatch.setattr(
            "importlib.util.find_spec",
            lambda name: None if name == "fastembed" else object(),
        )
        monkeypatch.setattr("memtomem.cli.web._missing_web_deps", lambda: None, raising=False)

        memory_dir = tmp_path / "scratch_memories"
        # Pre-condition: memory_dir does not exist yet.
        assert not memory_dir.exists()

        result = CliRunner().invoke(
            cli,
            [
                "init",
                "-y",
                "--provider",
                "onnx",
                "--model",
                "bge-m3",
                "--memory-dir",
                str(memory_dir),
                "--mcp",
                "skip",
            ],
        )

        assert result.exit_code != 0, result.output
        # The refused run must not have created the memory dir.
        assert not memory_dir.exists(), (
            "memory_dir must not be created on refused -y runs (gate must precede mkdir)"
        )


class TestYRefuseHintParity:
    """#403 — wizard fallback paths must mention the ``-y`` refuse semantic.

    #402 added a new axis: ``-y`` refuses non-zero while the interactive
    wizard still warns-and-saves. A user who copies a wizard choice into a
    scripted install gets an unexpected hard failure unless the wizard
    warning surfaces the asymmetry. These tests pin:
    (1) the helper's shape, and
    (2) that each missing-extra fallback path actually calls it.
    """

    def test_y_refuse_hint_shape(self) -> None:
        from memtomem.cli import init_cmd

        hint = init_cmd._y_refuse_hint("--provider onnx", "onnx")
        assert "mm init -y --provider onnx" in hint
        assert "memtomem[onnx]" in hint
        assert "refuses" in hint
        assert "scripted" in hint

    def test_wizard_fallback_paths_call_y_refuse_hint(self) -> None:
        """Source-scan pin — each fallback branch must call ``_y_refuse_hint``.

        Regex-based so a future reformatter splitting the call across
        lines doesn't break the scan. The call pattern is specific
        enough that false positives are unlikely.
        """
        import inspect
        import re

        from memtomem.cli import init_cmd

        embedding_src = inspect.getsource(init_cmd._step_embedding)
        language_src = inspect.getsource(init_cmd._step_language)

        def _count_calls(src: str, flag: str, extra: str) -> int:
            pattern = (
                r"_y_refuse_hint\(\s*"
                + re.escape(f'"{flag}"')
                + r"\s*,\s*"
                + re.escape(f'"{extra}"')
                + r"\s*\)"
            )
            return len(re.findall(pattern, src))

        assert _count_calls(embedding_src, "--provider onnx", "onnx") >= 1, (
            "ONNX fallback must surface -y refuse hint (#403)"
        )
        # ollama / openai are HTTP providers (httpx) that need NO PyPI client,
        # so ``-y`` no longer refuses on a missing client — the wizard must NOT
        # print a (now false) "-y will refuse" hint for them.
        assert _count_calls(embedding_src, "--provider ollama", "ollama") == 0, (
            "ollama needs no client package; -y does not refuse, so no refuse hint"
        )
        assert _count_calls(embedding_src, "--provider openai", "openai") == 0, (
            "openai needs no client package; -y does not refuse, so no refuse hint"
        )
        assert _count_calls(language_src, "--tokenizer kiwipiepy", "korean") >= 1, (
            "kiwipiepy fallback must surface -y refuse hint (#403)"
        )


class TestBackNavThroughSilentStep:
    """(#421) "b" at ``_step_mcp`` must skip past the silent
    ``_step_provider_dirs_auto`` and land on ``_step_memory_dir`` (the previous
    interactive step). Previously it landed on the silent step, which re-ran
    its banner and advanced straight back to ``_step_mcp`` — so "b" appeared
    to do nothing.

    Unit-level coverage of the skip mechanism lives in ``test_wizard.py``;
    this class is the CliRunner-level regression that proves the
    ``@silent_step`` decoration is wired into the real wizard.
    """

    def test_back_at_mcp_reaches_memory_dir_prompt(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """After "b" at the mcp step, the next interactive prompt must be
        ``_step_memory_dir`` (not mcp re-prompting). Pinned by counting the
        ``Memory Directory`` header occurrences in the captured output —
        pre-fix this would be 1 (back-nav never escaped the silent step),
        post-fix it is 2 (original visit + post-back re-entry)."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        # CliRunner substitutes sys.stdin; route through init_cmd._isatty seam
        # so the picker's non-TTY gate doesn't short-circuit (per
        # feedback_clirunner_isatty_seam.md).
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        memory_dir = tmp_path / "memories"
        # 1) picker: "2" (english, default)
        # 2) memory_dir: tmp path
        # 3) "Create it?": y  → path now exists
        # 4) (silent step runs: "No provider memory folders detected" banner)
        # 5) mcp: "b"  → expected to skip silent and land on memory_dir
        # 6) memory_dir: re-enter same path (no create prompt this time —
        #    the dir was created in step 3)
        # 7) (silent step runs again — forward direction, expected)
        # 8) mcp: "3" (skip — don't write to any client config)
        result = runner.invoke(init, [], input=f"2\n{memory_dir}\ny\nb\n{memory_dir}\n3\n")
        assert result.exit_code == 0, result.output

        # Memory Directory header must appear twice: initial visit + after
        # back-nav from mcp. If the silent-skip fix is missing, this is 1.
        assert result.output.count("Memory Directory") == 2, result.output
        # MCP header also appears twice: once before "b", once after the
        # back-and-forward trip. Mostly a sanity check on the flow.
        assert result.output.count("Connect to AI Editor") == 2, result.output

    def test_silent_step_banner_does_not_refire_on_back_pass(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Between the mcp prompt where "b" is typed and the next memory_dir
        prompt, the silent step's banner must NOT appear — that's the whole
        point of the skip. We slice the output at the first mcp prompt and
        look for banner strings in the window up to the second memory_dir
        header; any banner text there means the silent step re-ran on the
        back-pass (pre-fix behavior)."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        memory_dir = tmp_path / "memories"
        result = runner.invoke(init, [], input=f"2\n{memory_dir}\ny\nb\n{memory_dir}\n3\n")
        assert result.exit_code == 0, result.output

        out = result.output
        # Locate the two "Memory Directory" headers. The window *between*
        # them is the back-nav transition (mcp "b" → land on memory_dir).
        first_md = out.find("Memory Directory")
        second_md = out.find("Memory Directory", first_md + 1)
        assert first_md != -1 and second_md != -1, out

        # Grab the segment after user's "b" at mcp until the second memory_dir
        # header. Starting from the first mcp prompt keeps things anchored;
        # the silent banner must NOT appear in this slice.
        first_mcp = out.find("Connect to AI Editor")
        assert first_mcp != -1, out
        back_pass_window = out[first_mcp:second_md]
        assert "No provider memory folders detected" not in back_pass_window, (
            f"Silent-step banner re-fired on back-pass-through:\n{back_pass_window}"
        )


class TestStepHeaderPosition:
    """(#420) ``step_header`` reads its step number from
    ``state["_wizard_position"]`` (seeded by ``run_steps``) rather than from a
    hardcoded integer in each ``_step_*``. That lets the same step function
    show the correct position in every flow it participates in —
    ``--advanced`` (1..10), ``--preset X`` (1..3), and default-interactive
    (1..4 including the picker and the silent provider-dirs step).
    """

    def test_run_steps_seeds_position_before_each_step(self) -> None:
        """``run_steps`` must write ``(i + 1, len(steps))`` into state BEFORE
        invoking each step — step functions read that value during execution,
        so a post-hoc seed would be too late."""
        from memtomem.cli.wizard import run_steps

        observed: list[tuple[int, int]] = []

        def step_a(state: dict) -> None:
            observed.append(state["_wizard_position"])

        def step_b(state: dict) -> None:
            observed.append(state["_wizard_position"])

        def step_c(state: dict) -> None:
            observed.append(state["_wizard_position"])

        run_steps([step_a, step_b, step_c])

        assert observed == [(1, 3), (2, 3), (3, 3)]

    def test_position_reseeded_after_back_nav(self) -> None:
        """After ``StepBack`` + re-enter, the previous step sees its own
        position again (not the position it had moments before when the user
        hit 'b'). Pin this so the re-prompt header renders the correct
        number."""
        from memtomem.cli.wizard import StepBack, run_steps

        observed: list[tuple[str, tuple[int, int]]] = []
        call_counts = {"a": 0, "b": 0}

        def step_a(state: dict) -> None:
            call_counts["a"] += 1
            observed.append(("a", state["_wizard_position"]))

        def step_b(state: dict) -> None:
            call_counts["b"] += 1
            observed.append(("b", state["_wizard_position"]))
            if call_counts["b"] == 1:
                raise StepBack()

        run_steps([step_a, step_b])

        # a(1/2) → b(2/2) raises back → a(1/2) → b(2/2) completes.
        assert observed == [
            ("a", (1, 2)),
            ("b", (2, 2)),
            ("a", (1, 2)),
            ("b", (2, 2)),
        ]

    def test_step_header_renders_number_from_state(self) -> None:
        """With ``_wizard_position`` seeded, ``step_header`` prints
        ``N. Title`` where N is the 1-based current index."""
        import click
        from click.testing import CliRunner

        from memtomem.cli.wizard import step_header

        @click.command()
        def cmd() -> None:
            state = {"_wizard_position": (2, 4)}
            step_header(state, "Memory Directory")

        result = CliRunner().invoke(cmd, [])
        assert result.exit_code == 0
        assert "2. Memory Directory" in result.output

    def test_step_header_renders_without_number_when_state_unseeded(self) -> None:
        """Standalone use (no prior ``run_steps``) must not crash — fall
        back to an unnumbered title. Tests and external callers need this
        escape hatch."""
        import click
        from click.testing import CliRunner

        from memtomem.cli.wizard import step_header

        @click.command()
        def cmd() -> None:
            step_header({}, "Memory Directory")

        result = CliRunner().invoke(cmd, [])
        assert result.exit_code == 0
        assert "Memory Directory" in result.output
        # No leading "N. " prefix.
        assert "1. Memory Directory" not in result.output
        assert "2. Memory Directory" not in result.output

    def test_step_header_first_step_omits_back_hint(self) -> None:
        """(#422) On the first step of a ``run_steps`` call, ``b`` has
        nothing to return to -- the hint must only advertise ``q: quit``.
        The preset-flag path (``mm init --preset <name>``) goes straight
        to ``_step_memory_dir`` as step 0 of a fresh ``run_steps`` list,
        so the old unconditional ``(b: back, q: quit)`` was misleading."""
        import click
        from click.testing import CliRunner

        from memtomem.cli.wizard import step_header

        @click.command()
        def cmd() -> None:
            state = {"_wizard_position": (1, 3)}
            step_header(state, "Memory Directory")

        result = CliRunner().invoke(cmd, [])
        assert result.exit_code == 0
        assert "1. Memory Directory" in result.output
        assert "(q: quit)" in result.output
        assert "b: back" not in result.output

    def test_step_header_later_step_shows_back_hint(self) -> None:
        """Regression guard: any step after the first (e.g. memory-dir
        following the interactive preset picker) must still show
        ``(b: back, q: quit)`` because ``b`` has a real destination."""
        import click
        from click.testing import CliRunner

        from memtomem.cli.wizard import step_header

        @click.command()
        def cmd() -> None:
            state = {"_wizard_position": (2, 4)}
            step_header(state, "Memory Directory")

        result = CliRunner().invoke(cmd, [])
        assert result.exit_code == 0
        assert "2. Memory Directory" in result.output
        assert "(b: back, q: quit)" in result.output

    def test_advanced_flow_shows_numbers_1_through_10(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``mm init --advanced`` runs 10 interactive steps — step_header
        must show ``1.`` through ``10.`` respectively. Pre-fix this was
        correct by coincidence (hardcoded N == actual position); the
        regression guard pins it to the new seed path."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)

        runner = CliRunner()
        memory_dir = tmp_path / "memories"
        # Advanced walks through every step. Inputs chosen to minimize
        # branching: no-rerank, no provider dirs, skip mcp.
        # 1. embedding: 1 (quick start / none)
        # 2. reranker: N (no)
        # 3. memory_dir: path, y create
        # 4. provider_dirs: N (skip for all three categories)
        # 5. storage: accept default (empty line)
        # 6. namespace: N (no auto-ns), default namespace accept
        # 7. search: top_k default (empty), decay N
        # 8. language: 1 (english)
        # 9. settings hooks: N
        # 10. mcp: 3 (skip)
        result = runner.invoke(
            init,
            ["--advanced"],
            input=f"1\nn\n{memory_dir}\ny\nn\nn\nn\n\nn\n\n\n\nn\n1\nn\n3\n",
        )
        assert result.exit_code == 0, result.output

        out = result.output
        assert "1. Embedding Provider" in out
        assert "2. Reranker (optional)" in out
        assert "3. Memory Directory" in out
        assert "4. Provider Memory Folders" in out
        assert "5. Storage" in out
        assert "6. Namespace" in out
        assert "7. Search" in out
        assert "8. Language" in out
        assert "9. Claude Code Hooks" in out
        assert "10. Connect to AI Editor" in out

    def test_default_preset_flow_renumbers_from_picker(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``mm init`` (default interactive, preset chosen) runs
        ``[picker, memory_dir, provider_dirs_auto, mcp]`` — four steps.
        ``_step_memory_dir`` is position 2/4 here, not 3 (which was the
        hardcoded value correct only for ``--advanced``). Silent
        ``_step_provider_dirs_auto`` sits at position 3 but prints no
        header of its own; mcp is 4."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        memory_dir = tmp_path / "memories"
        # picker: 2 (english) → memory_dir → create → (silent) → mcp skip
        result = runner.invoke(init, [], input=f"2\n{memory_dir}\ny\n3\n")
        assert result.exit_code == 0, result.output

        out = result.output
        assert "2. Memory Directory" in out
        assert "4. Connect to AI Editor" in out
        # Pre-fix values must NOT appear (they were the bug).
        assert "3. Memory Directory" not in out
        assert "10. Connect to AI Editor" not in out

    def test_explicit_preset_flow_renumbers_from_memory_dir(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``mm init --preset korean`` runs only
        ``[memory_dir, provider_dirs_auto, mcp]`` — three steps, memory_dir
        at position 1. Pre-fix this showed ``3. Memory Directory`` which
        was the bug's most visible symptom."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        memory_dir = tmp_path / "memories"
        result = runner.invoke(init, ["--preset", "korean"], input=f"{memory_dir}\ny\n3\n")
        assert result.exit_code == 0, result.output

        out = result.output
        assert "1. Memory Directory" in out
        assert "3. Connect to AI Editor" in out
        # Old hardcoded values must not leak.
        assert "3. Memory Directory" not in out
        assert "10. Connect to AI Editor" not in out


class TestPresetMcpFlagPreAnswer:
    """(#449) ``mm init --preset <name> --mcp <mode>`` must honor ``--mcp``
    without re-prompting.

    Before the fix, ``_override_from_flags`` set ``state["mcp_choice"]`` from
    the flag in the ``elif preset:`` branch, then ``run_steps`` invoked
    ``_step_mcp`` which unconditionally overwrote that key with the prompt
    default. The flag's value was silently discarded — the user had to type
    the choice again (and was confused when their flag appeared to do
    nothing). The fix makes ``_step_mcp`` respect a pre-set ``mcp_choice``
    and return early.
    """

    def test_preset_with_mcp_skip_does_not_prompt(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``--preset minimal --mcp skip`` must reach Setup complete without
        ever rendering the MCP menu. Inputs cover only memory_dir + create-y;
        if the MCP prompt fires, stdin runs out and CliRunner aborts."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        memory_dir = tmp_path / "memories"
        result = runner.invoke(
            init,
            ["--preset", "minimal", "--mcp", "skip"],
            input=f"{memory_dir}\ny\n",
        )
        assert result.exit_code == 0, result.output

        out = result.output
        # The menu header must not appear — that's the whole point of the flag.
        assert "Connect to AI Editor" not in out, (
            "--mcp pre-answer must skip the MCP prompt entirely"
        )
        # And mcp_choice == 3 means no .mcp.json side effect and no
        # "Claude Code:" / "MCP config:" lines.
        assert "MCP config:" not in out
        assert "Claude Code:" not in out
        assert "Setup complete" in out

    def test_preset_with_mcp_json_writes_mcp_json_without_prompt(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """``--mcp json`` is mcp_choice=2 which writes ``.mcp.json`` to cwd.
        The prompt must not fire, but the side effect must still happen."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        memory_dir = tmp_path / "memories"
        result = runner.invoke(
            init,
            ["--preset", "minimal", "--mcp", "json"],
            input=f"{memory_dir}\ny\n",
        )
        assert result.exit_code == 0, result.output

        out = result.output
        assert "Connect to AI Editor" not in out
        # Side effect from mcp_choice=2 must still fire.
        assert "MCP config: wrote ./.mcp.json" in out
        assert (tmp_path / ".mcp.json").exists()

    def test_preset_without_mcp_flag_still_prompts(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Omitting ``--mcp`` keeps the interactive prompt — the flag's
        absence must NOT regress into a silent skip. Pairs with
        ``test_explicit_preset_flow_renumbers_from_memory_dir`` (which
        covers the positional header) by specifically pinning that the menu
        body is still rendered."""
        from click.testing import CliRunner

        from memtomem.cli.init_cmd import init

        set_home(monkeypatch, tmp_path)
        monkeypatch.setattr("memtomem.cli.init_cmd._isatty", lambda: True)
        monkeypatch.setattr(
            "memtomem.config._detect_provider_dirs",
            lambda: {"claude-memory": [], "claude-plans": [], "codex": []},
        )

        runner = CliRunner()
        memory_dir = tmp_path / "memories"
        # Trailing "3" answers the MCP prompt with "skip".
        result = runner.invoke(
            init,
            ["--preset", "minimal"],
            input=f"{memory_dir}\ny\n3\n",
        )
        assert result.exit_code == 0, result.output

        out = result.output
        assert "Connect to AI Editor" in out
        assert "Claude Code (run 'claude mcp add' automatically)" in out

    def test_step_mcp_unit_skips_when_mcp_choice_preset(
        self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Unit-level pin: ``_step_mcp`` with pre-set ``mcp_choice`` must not
        call ``nav_prompt`` and must leave the key's value intact. Guards
        against a future refactor that re-introduces an unconditional
        assignment."""
        from memtomem.cli.init_cmd import _step_mcp

        def _explode(*a: object, **kw: object) -> int:
            raise AssertionError("nav_prompt must not be called when mcp_choice is pre-set")

        monkeypatch.setattr("memtomem.cli.init_cmd.nav_prompt", _explode)

        state: dict = {"mcp_choice": 2}
        _step_mcp(state)

        assert state["mcp_choice"] == 2
        out = capsys.readouterr().out
        # No header, no menu lines — the step is a no-op in this branch.
        assert out == ""


class TestOllamaWindowsHangFix:
    """Tests for the Windows pipe-inheritance hang fix.

    On Windows, ``ollama list`` auto-spawns the Ollama server process, which
    inherits the stdout/stderr pipe handles created by
    ``subprocess.run(capture_output=True)``. After ``ollama list`` is killed
    by the 5-second timeout, Python blocks forever trying to drain the pipes
    (the server keeps them open). The fix uses DEVNULL on win32 so no pipes
    are ever created.
    """

    def test_ollama_running_uses_devnull_on_win32(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """On win32, _ollama_running must pass DEVNULL for stdout/stderr and
        must NOT use capture_output — otherwise the spawned Ollama server
        inherits the pipe handles and blocks pipe-drain indefinitely."""
        import subprocess as _sp

        from memtomem.cli import init_cmd

        captured: dict = {}

        def _fake_run(cmd: list, **kw: object) -> _sp.CompletedProcess:
            captured.update(kw)
            return _sp.CompletedProcess(cmd, returncode=0)

        monkeypatch.setattr("memtomem.cli.init_cmd.sys.platform", "win32")
        monkeypatch.setattr(init_cmd.subprocess, "run", _fake_run)

        result = init_cmd._ollama_running()

        assert result is True
        assert captured.get("stdout") is _sp.DEVNULL
        assert captured.get("stderr") is _sp.DEVNULL
        assert not captured.get("capture_output")


class TestOllamaHasModelTriState:
    """``_ollama_has_model`` returns ``bool | None`` so callers can
    distinguish "server unreachable" from "model not installed" (#626).
    The original ``False``-on-error conflation was the root of the
    "server timeout misread as model absent" bug."""

    def test_returncode_nonzero_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """``ollama list`` exiting non-zero (e.g. server unreachable) must
        return ``None``, not ``False``."""
        import subprocess as _sp

        from memtomem.cli import init_cmd

        def _fake_run(cmd: list, **kw: object) -> _sp.CompletedProcess:
            return _sp.CompletedProcess(cmd, returncode=1, stdout="", stderr="Error: timed out")

        monkeypatch.setattr(init_cmd, "_run", _fake_run)
        assert init_cmd._ollama_has_model("nomic-embed-text") is None

    def test_timeout_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
        from memtomem.cli import init_cmd

        def _fake_run(cmd: list, **kw: object) -> object:
            raise subprocess.TimeoutExpired(cmd=cmd, timeout=5)

        monkeypatch.setattr(init_cmd, "_run", _fake_run)
        assert init_cmd._ollama_has_model("nomic-embed-text") is None

    def test_filenotfound_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
        from memtomem.cli import init_cmd

        def _fake_run(cmd: list, **kw: object) -> object:
            raise FileNotFoundError()

        monkeypatch.setattr(init_cmd, "_run", _fake_run)
        assert init_cmd._ollama_has_model("nomic-embed-text") is None

    def test_model_present_returns_true(self, monkeypatch: pytest.MonkeyPatch) -> None:
        import subprocess as _sp

        from memtomem.cli import init_cmd

        def _fake_run(cmd: list, **kw: object) -> _sp.CompletedProcess:
            return _sp.CompletedProcess(
                cmd, returncode=0, stdout="nomic-embed-text:latest    768d ..."
            )

        monkeypatch.setattr(init_cmd, "_run", _fake_run)
        assert init_cmd._ollama_has_model("nomic-embed-text") is True

    def test_model_absent_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Server reachable but model missing — ``False``, distinct from
        ``None``."""
        import subprocess as _sp

        from memtomem.cli import init_cmd

        def _fake_run(cmd: list, **kw: object) -> _sp.CompletedProcess:
            return _sp.CompletedProcess(cmd, returncode=0, stdout="other-model:latest")

        monkeypatch.setattr(init_cmd, "_run", _fake_run)
        assert init_cmd._ollama_has_model("nomic-embed-text") is False


class TestStepEmbeddingOllamaGuards:
    """``_step_embedding`` Ollama branch must surface failures via
    ``fail_step`` instead of silently advancing the wizard (#626).

    These tests drive the step directly with monkeypatched probes and
    fed-in input. The precise pin is: when an action fails, the wizard
    either retries, returns to the previous step, or cancels — it does
    NOT fall through to subsequent steps."""

    @staticmethod
    def _patch_ollama_env(
        monkeypatch: pytest.MonkeyPatch,
        *,
        running: object,
        has_model: object,
    ) -> None:
        """Patch the Ollama probes so the step exercises only the path
        under test. ``running`` and ``has_model`` are callables (allowing
        per-call sequencing) or constants."""
        from memtomem.cli import init_cmd

        monkeypatch.setattr(init_cmd, "_ollama_available", lambda: True)
        monkeypatch.setattr(init_cmd, "_have_module", lambda mod: True)
        monkeypatch.setattr(
            init_cmd,
            "_ollama_running",
            running if callable(running) else (lambda r=running: r),
        )
        monkeypatch.setattr(
            init_cmd,
            "_ollama_has_model",
            has_model if callable(has_model) else (lambda model, hm=has_model: hm),
        )

    def test_server_not_running_q_cancels_wizard(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Server unreachable + user picks 'q' → ``WizardCancel``. The
        wizard must NOT advance past ``_step_embedding``."""
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd
        from memtomem.cli.wizard import WizardCancel

        self._patch_ollama_env(monkeypatch, running=False, has_model=True)

        captured: dict[str, BaseException | None] = {"exc": None}

        @click.command()
        def cmd() -> None:
            try:
                init_cmd._step_embedding({})
            except WizardCancel as exc:
                captured["exc"] = exc

        # choice=3 (Ollama) → server check fails → 'q'
        result = CliRunner().invoke(cmd, [], input="3\nq\n")
        assert "Ollama server is not reachable" in result.output
        assert isinstance(captured["exc"], WizardCancel)

    def test_server_not_running_r_retries_until_up(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Server down → 'r' → server up on retry → step completes
        normally without re-prompting the provider choice."""
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd

        running_calls = {"n": 0}

        def fake_running() -> bool:
            running_calls["n"] += 1
            return running_calls["n"] >= 2

        self._patch_ollama_env(monkeypatch, running=fake_running, has_model=True)

        state: dict = {}

        @click.command()
        def cmd() -> None:
            init_cmd._step_embedding(state)

        # choice=3 → server fails → r → server up → model 1
        result = CliRunner().invoke(cmd, [], input="3\nr\n1\n")
        assert result.exit_code == 0
        assert "Ollama server is not reachable" in result.output
        assert state.get("provider") == "ollama"
        assert state.get("model") == "nomic-embed-text"
        assert running_calls["n"] >= 2

    def test_has_model_none_b_raises_step_back(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Tri-state ``None`` from ``_ollama_has_model`` (server returned
        an error / timed out) surfaces explicitly — user picks 'b' to
        return to the embedding choice."""
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd
        from memtomem.cli.wizard import StepBack

        self._patch_ollama_env(monkeypatch, running=True, has_model=None)

        captured: dict[str, BaseException | None] = {"exc": None}

        @click.command()
        def cmd() -> None:
            try:
                init_cmd._step_embedding({})
            except StepBack as exc:
                captured["exc"] = exc

        # choice=3 → model 1 → has_model None → 'b'
        result = CliRunner().invoke(cmd, [], input="3\n1\nb\n")
        assert "Could not verify whether" in result.output
        assert isinstance(captured["exc"], StepBack)

    def test_pull_failure_r_retries_pull_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Pull fails → 'r' → second pull succeeds. The retry must NOT
        re-prompt the model choice (inner retry loop)."""
        import click
        import subprocess as _sp

        from click.testing import CliRunner

        from memtomem.cli import init_cmd

        # Server up, model not yet installed → pull path.
        self._patch_ollama_env(monkeypatch, running=True, has_model=False)

        pull_calls = {"n": 0}

        def fake_run(cmd: list, **kw: object) -> _sp.CompletedProcess:
            pull_calls["n"] += 1
            if pull_calls["n"] == 1:
                return _sp.CompletedProcess(cmd, returncode=1, stdout="", stderr="Error: timed out")
            return _sp.CompletedProcess(cmd, returncode=0, stdout="", stderr="")

        monkeypatch.setattr(init_cmd, "_run", fake_run)

        state: dict = {}

        @click.command()
        def cmd() -> None:
            init_cmd._step_embedding(state)

        # choice=3 → model 1 → pull yes → fail → r → success
        result = CliRunner().invoke(cmd, [], input="3\n1\nY\nr\n")
        assert result.exit_code == 0
        assert "Pull failed" in result.output
        assert "pulled successfully" in result.output
        assert state.get("provider") == "ollama"
        assert pull_calls["n"] == 2

    def test_pull_failure_q_does_not_advance_to_next_step(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """The core #626 invariant: when ``ollama pull`` fails and the
        user picks 'q', the wizard does NOT silently advance to the next
        step. Pinned by composing ``_step_embedding`` with a sentinel
        next step that must never run."""
        import click
        import subprocess as _sp

        from click.testing import CliRunner

        from memtomem.cli import init_cmd
        from memtomem.cli.wizard import run_steps

        self._patch_ollama_env(monkeypatch, running=True, has_model=False)

        def fake_run(cmd: list, **kw: object) -> _sp.CompletedProcess:
            return _sp.CompletedProcess(cmd, returncode=1, stdout="", stderr="Error: timed out")

        monkeypatch.setattr(init_cmd, "_run", fake_run)

        next_step_called: list[bool] = []

        def sentinel_next_step(state: dict) -> None:
            next_step_called.append(True)

        @click.command()
        def cmd() -> None:
            run_steps([init_cmd._step_embedding, sentinel_next_step])

        # choice=3 → model 1 → pull yes → fail → q
        result = CliRunner().invoke(cmd, [], input="3\n1\nY\nq\n")
        assert "Pull failed" in result.output
        assert "Wizard cancelled" in result.output
        # The whole point of #626: a failed pull does NOT silently
        # advance to the next step.
        assert next_step_called == []

    def test_has_model_none_r_retries_then_succeeds(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """``has_model=None`` (server returned an error) → 'r' → outer
        retry loop re-runs the server check and ``_ollama_has_model``;
        on the second call the model is detected and the step completes.
        Symmetric counterpart to ``test_pull_failure_r_retries_pull_only``
        for the tri-state ambiguous branch."""
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd

        has_model_calls = {"n": 0}

        def fake_has_model(model: str) -> bool | None:
            has_model_calls["n"] += 1
            # First call: server errored. Second call: model is ready.
            return None if has_model_calls["n"] == 1 else True

        self._patch_ollama_env(monkeypatch, running=True, has_model=fake_has_model)

        state: dict = {}

        @click.command()
        def cmd() -> None:
            init_cmd._step_embedding(state)

        # choice=3 → model 1 → has_model None → r → model 1 (re-prompted) → ready
        result = CliRunner().invoke(cmd, [], input="3\n1\nr\n1\n")
        assert result.exit_code == 0
        assert "Could not verify whether" in result.output
        assert "is ready" in result.output
        assert state.get("provider") == "ollama"
        assert state.get("model") == "nomic-embed-text"
        assert has_model_calls["n"] == 2


class TestStepEmbeddingOpenAIGuard:
    """The OpenAI bad-key + decline path raises ``WizardCancel`` (was
    ``SystemExit(1)`` pre-#626) so cancellation is uniform with the
    ``q`` shortcut. The exit-code change (1 → 0 via ``run_steps``) is
    deliberate: the user actively chose to cancel, not error out."""

    def test_invalid_key_decline_continue_raises_wizard_cancel(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd
        from memtomem.cli.wizard import WizardCancel

        monkeypatch.setattr(init_cmd, "_have_module", lambda mod: True)
        monkeypatch.setattr(init_cmd, "_test_openai_key", lambda key: False)

        captured: dict[str, BaseException | None] = {"exc": None}

        @click.command()
        def cmd() -> None:
            try:
                init_cmd._step_embedding({})
            except WizardCancel as exc:
                captured["exc"] = exc

        # choice=4 (OpenAI) → model 1 → fake-key → "Continue anyway?" N
        result = CliRunner().invoke(cmd, [], input="4\n1\nfake-key\nN\n")
        assert "API key test failed" in result.output
        assert isinstance(captured["exc"], WizardCancel)

    def test_invalid_key_decline_does_not_advance_to_next_step(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """Composed pin: when the user declines to continue with a bad
        OpenAI key, the wizard cancels via the ``q`` path — the next
        step does NOT run, and ``run_steps`` exits 0 (deliberate cancel,
        not an error)."""
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd
        from memtomem.cli.wizard import run_steps

        monkeypatch.setattr(init_cmd, "_have_module", lambda mod: True)
        monkeypatch.setattr(init_cmd, "_test_openai_key", lambda key: False)

        next_step_called: list[bool] = []

        def sentinel_next_step(state: dict) -> None:
            next_step_called.append(True)

        @click.command()
        def cmd() -> None:
            run_steps([init_cmd._step_embedding, sentinel_next_step])

        result = CliRunner().invoke(cmd, [], input="4\n1\nfake-key\nN\n")
        assert "Wizard cancelled" in result.output
        assert next_step_called == []
        # ``run_steps`` exits 0 on WizardCancel (was 1 under SystemExit(1)).
        assert result.exit_code == 0


class TestStepMemoryDirFsGuards:
    """``_step_memory_dir`` must surface ``mkdir`` failures via
    ``fail_step`` instead of crashing the wizard with an uncaught
    PermissionError / OSError stack trace (#664). Symmetric to the
    Ollama-branch guards in ``TestStepEmbeddingOllamaGuards``."""

    def test_mkdir_permission_error_q_cancels(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """``mkdir`` raises PermissionError → user picks 'q' → wizard
        cancels with ``WizardCancel`` instead of crashing on the raw
        exception."""
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd
        from memtomem.cli.wizard import WizardCancel

        target = tmp_path / "nope"

        def boom(self: Path, *a: object, **kw: object) -> None:
            raise PermissionError(13, "Permission denied", str(self))

        monkeypatch.setattr(Path, "mkdir", boom)

        captured: dict[str, BaseException | None] = {"exc": None}

        @click.command()
        def cmd() -> None:
            try:
                init_cmd._step_memory_dir({})
            except WizardCancel as exc:
                captured["exc"] = exc

        # path → confirm create → fail → q
        result = CliRunner().invoke(cmd, [], input=f"{target}\nY\nq\n")
        assert "Could not create" in result.output
        assert "Permission denied" in result.output
        assert isinstance(captured["exc"], WizardCancel)

    def test_mkdir_permission_error_r_retries_until_success(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """``mkdir`` fails once, succeeds on retry — the wizard does NOT
        re-prompt the directory path on retry (recovery is local to the
        mkdir, not the whole step)."""
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd

        target = tmp_path / "memories"
        calls = {"n": 0}

        original_mkdir = Path.mkdir

        def flaky_mkdir(self: Path, *a: object, **kw: object) -> None:
            calls["n"] += 1
            if calls["n"] == 1:
                raise PermissionError(13, "Permission denied", str(self))
            return original_mkdir(self, *a, **kw)

        monkeypatch.setattr(Path, "mkdir", flaky_mkdir)

        state: dict = {}

        @click.command()
        def cmd() -> None:
            init_cmd._step_memory_dir(state)

        # path → Y → mkdir fails → r → mkdir succeeds (no re-prompt)
        result = CliRunner().invoke(cmd, [], input=f"{target}\nY\nr\n")
        assert result.exit_code == 0
        assert "Could not create" in result.output
        assert f"Created {target}" in result.output
        assert state.get("memory_dir") == str(target)
        assert calls["n"] == 2

    def test_mkdir_failure_does_not_advance_to_next_step(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """Core #664 invariant: an uncaught crash is replaced by a
        deliberate cancel. When mkdir fails and the user picks 'q',
        the next step must NOT run."""
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd
        from memtomem.cli.wizard import run_steps

        target = tmp_path / "nope"

        def boom(self: Path, *a: object, **kw: object) -> None:
            raise OSError(28, "No space left on device", str(self))

        monkeypatch.setattr(Path, "mkdir", boom)

        next_step_called: list[bool] = []

        def sentinel_next_step(state: dict) -> None:
            next_step_called.append(True)

        @click.command()
        def cmd() -> None:
            run_steps([init_cmd._step_memory_dir, sentinel_next_step])

        result = CliRunner().invoke(cmd, [], input=f"{target}\nY\nq\n")
        assert "No space left on device" in result.output
        assert "Wizard cancelled" in result.output
        assert next_step_called == []
        assert result.exit_code == 0


class TestStepSettingsFsGuards:
    """``_step_settings`` must surface ``mkdir`` / ``write_text`` failures
    via ``fail_step`` instead of crashing on a read-only project root or
    full disk (#664)."""

    def test_write_oserror_q_cancels(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
        """``write_text`` raises OSError → user picks 'q' → wizard
        cancels cleanly. Without the guard, the wizard would crash with
        an uncaught stack trace mid-step."""
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd
        from memtomem.cli.wizard import WizardCancel

        # ``_step_settings`` short-circuits if ``~/.claude`` is missing.
        # Patch ``Path.home`` so the body runs — the ``HOME`` env var isn't
        # honored by ``Path.home()`` on Windows (it uses ``USERPROFILE``).
        home = tmp_path / "home"
        (home / ".claude").mkdir(parents=True)
        monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
        monkeypatch.chdir(tmp_path)

        def boom(self: Path, *a: object, **kw: object) -> None:
            raise PermissionError(13, "Permission denied", str(self))

        monkeypatch.setattr(Path, "write_text", boom)

        captured: dict[str, BaseException | None] = {"exc": None}

        @click.command()
        def cmd() -> None:
            try:
                init_cmd._step_settings({})
            except WizardCancel as exc:
                captured["exc"] = exc

        # configure-hooks? Y → write fails → q
        result = CliRunner().invoke(cmd, [], input="Y\nq\n")
        assert "Could not write" in result.output
        assert "Permission denied" in result.output
        assert isinstance(captured["exc"], WizardCancel)

    def test_write_oserror_r_retries_until_success(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """``write_text`` fails once, succeeds on retry — the wizard does
        NOT re-prompt the hooks confirmation on retry."""
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd

        home = tmp_path / "home"
        (home / ".claude").mkdir(parents=True)
        monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
        monkeypatch.chdir(tmp_path)

        calls = {"n": 0}
        original_write = Path.write_text

        def flaky_write(self: Path, *a: object, **kw: object) -> int:
            calls["n"] += 1
            if calls["n"] == 1:
                raise PermissionError(13, "Permission denied", str(self))
            return original_write(self, *a, **kw)

        monkeypatch.setattr(Path, "write_text", flaky_write)
        # ``generate_all_settings`` writes to ~/.claude/settings.json after
        # the canonical write succeeds; stub it so the test stays scoped to
        # the guard under test.
        monkeypatch.setattr(
            "memtomem.context.settings.generate_all_settings",
            lambda root, *, scope, **_: {},
        )

        state: dict = {}

        @click.command()
        def cmd() -> None:
            init_cmd._step_settings(state)

        # confirm-hooks Y → write fails → r → write succeeds
        result = CliRunner().invoke(cmd, [], input="Y\nr\n")
        assert result.exit_code == 0
        assert "Could not write" in result.output
        assert state.get("settings_hooks") is True
        # Two attempts: the first raises, the second writes the file.
        assert calls["n"] == 2

    def test_write_failure_does_not_advance_to_next_step(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        """Core #664 invariant for ``_step_settings``: a write failure
        plus 'q' cancels deliberately — the next step does not run."""
        import click
        from click.testing import CliRunner

        from memtomem.cli import init_cmd
        from memtomem.cli.wizard import run_steps

        home = tmp_path / "home"
        (home / ".claude").mkdir(parents=True)
        monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
        monkeypatch.chdir(tmp_path)

        def boom(self: Path, *a: object, **kw: object) -> None:
            raise OSError(28, "No space left on device", str(self))

        monkeypatch.setattr(Path, "write_text", boom)

        next_step_called: list[bool] = []

        def sentinel_next_step(state: dict) -> None:
            next_step_called.append(True)

        @click.command()
        def cmd() -> None:
            run_steps([init_cmd._step_settings, sentinel_next_step])

        result = CliRunner().invoke(cmd, [], input="Y\nq\n")
        assert "No space left on device" in result.output
        assert "Wizard cancelled" in result.output
        assert next_step_called == []
        assert result.exit_code == 0
