"""Web lifespan auto-sync gating — follow-up to issue #349.

The web lifespan used to overwrite the runtime embedding config whenever the
DB-stored ``stored_embedding_info`` differed from config, then call
``storage.clear_embedding_mismatch()`` to suppress the mismatch banner. For
normal model drift (e.g. user edited ``config.json`` to a different onnx
model without running ``mm embedding-reset``) that soft-sync was benign.

For the dim=0 degraded-mode case introduced by #349, the stored "embedding"
is NoopEmbedder (``provider=none``, ``dim=0``) — auto-syncing silently
downgrades the user's configured onnx/bge-m3 to BM25-only AND swallows the
banner, so the user never sees the broken state and has no path to recover
it from the web UI. The gate below keeps the auto-sync only when the server
came up clean (``embedding_broken is None``) so the recovery banner +
``POST /api/embedding-reset`` flow stays reachable in degraded mode.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi import FastAPI

from memtomem.web.app import _lifespan


@dataclass
class _FakeEmbeddingCfg:
    provider: str = "onnx"
    model: str = "bge-m3"
    dimension: int = 1024


@dataclass
class _FakeIndexingCfg:
    memory_dirs: list = None  # type: ignore[assignment]

    def __post_init__(self) -> None:
        self.memory_dirs = self.memory_dirs or []


@dataclass
class _FakeSchedulerCfg:
    enabled: bool = False


@dataclass
class _FakePolicyCfg:
    enabled: bool = False


@dataclass
class _FakeConfig:
    embedding: _FakeEmbeddingCfg
    indexing: _FakeIndexingCfg
    scheduler: _FakeSchedulerCfg = field(default_factory=_FakeSchedulerCfg)
    policy: _FakePolicyCfg = field(default_factory=_FakePolicyCfg)


def _make_components(
    *,
    embedding_broken: dict[str, Any] | None,
    stored_info: dict[str, Any],
    cfg_provider: str = "onnx",
    cfg_model: str = "bge-m3",
    cfg_dim: int = 1024,
    scheduler_enabled: bool = False,
    policy_enabled: bool = False,
) -> MagicMock:
    """Build a mock ``Components`` for ``_lifespan``.

    ``storage.clear_embedding_mismatch`` and ``storage.stored_embedding_info``
    are probed by the auto-sync block; the rest is stubbed just enough to
    keep the context manager from raising before it yields.
    """
    storage = MagicMock()
    storage.stored_embedding_info = stored_info
    storage.clear_embedding_mismatch = MagicMock()

    comp = MagicMock()
    comp.config = _FakeConfig(
        embedding=_FakeEmbeddingCfg(provider=cfg_provider, model=cfg_model, dimension=cfg_dim),
        indexing=_FakeIndexingCfg(),
        scheduler=_FakeSchedulerCfg(enabled=scheduler_enabled),
        policy=_FakePolicyCfg(enabled=policy_enabled),
    )
    comp.storage = storage
    comp.embedder = MagicMock()
    comp.search_pipeline = MagicMock()
    comp.index_engine = MagicMock()
    comp.embedding_broken = embedding_broken
    return comp


async def _run_lifespan(comp: MagicMock) -> FastAPI:
    """Enter and exit ``_lifespan`` with a mocked ``create_components``.

    The FileWatcher patch keeps the lifespan from spinning a real
    watchdog Observer thread on every test. Tests that need to assert
    on ``watcher.start`` / ``watcher.stop`` directly patch FileWatcher
    themselves with a spy — see ``test_lifespan_starts_and_stops_file_watcher``.
    """
    app = FastAPI()
    fake_watcher = MagicMock()
    fake_watcher.start = AsyncMock()
    fake_watcher.stop = AsyncMock()
    with (
        patch("memtomem.server.component_factory.create_components", AsyncMock(return_value=comp)),
        patch("memtomem.server.component_factory.close_components", AsyncMock()),
        # The lifespan also instantiates DedupScanner — harmless to stub.
        patch("memtomem.search.dedup.DedupScanner", MagicMock()),
        patch("memtomem.indexing.watcher.FileWatcher", lambda *_a, **_kw: fake_watcher),
    ):
        async with _lifespan(app):
            pass
    return app


async def test_auto_sync_skipped_when_degraded():
    """With ``embedding_broken`` set, the lifespan must NOT overwrite config
    or clear the mismatch — the banner + reset button flow depends on those
    signals being left intact. Regression for issue #349 follow-up."""
    comp = _make_components(
        embedding_broken={
            "dimension_mismatch": True,
            "model_mismatch": True,
            "stored": {"dimension": 0, "provider": "none", "model": ""},
            "configured": {"dimension": 1024, "provider": "onnx", "model": "bge-m3"},
        },
        stored_info={"dimension": 0, "provider": "none", "model": ""},
    )

    await _run_lifespan(comp)

    # Config must still reflect what the user configured, not the legacy
    # NoopEmbedder meta row.
    assert comp.config.embedding.provider == "onnx"
    assert comp.config.embedding.model == "bge-m3"
    assert comp.config.embedding.dimension == 1024

    # The mismatch flag must survive so ``/api/embedding-status`` can surface
    # it and the UI banner can fire.
    comp.storage.clear_embedding_mismatch.assert_not_called()


async def test_auto_sync_runs_when_not_degraded():
    """Non-degraded model drift keeps the pre-#349 soft-sync behavior —
    config follows DB and the mismatch flag is cleared so the banner does
    not fire for drift that was already reconciled at startup."""
    comp = _make_components(
        embedding_broken=None,
        stored_info={"dimension": 384, "provider": "onnx", "model": "minilm-l12"},
        cfg_provider="onnx",
        cfg_model="bge-m3",
        cfg_dim=1024,
    )

    await _run_lifespan(comp)

    assert comp.config.embedding.model == "minilm-l12"
    assert comp.config.embedding.dimension == 384
    assert comp.config.embedding.provider == "onnx"
    comp.storage.clear_embedding_mismatch.assert_called_once()


@pytest.mark.parametrize(
    "stored_info",
    [
        None,
        {"dimension": 1024, "provider": "onnx", "model": "bge-m3"},  # matches config
    ],
)
async def test_auto_sync_noop_when_no_drift(stored_info):
    """When there's no stored info OR stored matches config, the sync block
    is a no-op regardless of ``embedding_broken`` — validates the gate
    doesn't accidentally enable sync on the non-drift path."""
    comp = _make_components(
        embedding_broken=None,
        stored_info=stored_info,
    )

    await _run_lifespan(comp)

    assert comp.config.embedding.provider == "onnx"
    comp.storage.clear_embedding_mismatch.assert_not_called()


async def test_scheduler_enabled_warns_in_web_lifespan(caplog):
    """``mm web`` does not run the schedule dispatcher (HealthWatchdog is
    wired only in the MCP server lifespan). Mirror the loud warning emitted
    by ``AppContext.ensure_initialized`` so users registering schedules
    against a web-only entry get a startup signal instead of silent
    null ``last_run_status``. Regression for issue #526."""
    import logging

    comp = _make_components(
        embedding_broken=None,
        stored_info=None,
        scheduler_enabled=True,
    )

    with caplog.at_level(logging.WARNING, logger="memtomem.web.app"):
        await _run_lifespan(comp)

    assert any(
        "scheduler.enabled=true" in r.message and "mm web" in r.message for r in caplog.records
    ), [r.message for r in caplog.records]


async def test_scheduler_disabled_no_warning(caplog):
    """No warning when scheduler is off — avoid noise on the default path."""
    import logging

    comp = _make_components(
        embedding_broken=None,
        stored_info=None,
        scheduler_enabled=False,
    )

    with caplog.at_level(logging.WARNING, logger="memtomem.web.app"):
        await _run_lifespan(comp)

    assert not any("scheduler.enabled" in r.message for r in caplog.records)


async def test_policy_enabled_warns_in_web_lifespan(caplog):
    """``mm web`` does not start ``PolicyScheduler`` (wired only in
    ``AppContext.ensure_initialized`` on the MCP server lifespan). Mirror the
    ``scheduler.enabled`` warning so users running ``mm web`` with
    ``policy.enabled=true`` see a loud signal at startup."""
    import logging

    comp = _make_components(
        embedding_broken=None,
        stored_info=None,
        policy_enabled=True,
    )

    with caplog.at_level(logging.WARNING, logger="memtomem.web.app"):
        await _run_lifespan(comp)

    assert any(
        "policy.enabled=true" in r.message and "mm web" in r.message for r in caplog.records
    ), [r.message for r in caplog.records]


async def test_policy_disabled_no_warning(caplog):
    """No warning when policy is off — avoid noise on the default path."""
    import logging

    comp = _make_components(
        embedding_broken=None,
        stored_info=None,
        policy_enabled=False,
    )

    with caplog.at_level(logging.WARNING, logger="memtomem.web.app"):
        await _run_lifespan(comp)

    assert not any("policy.enabled" in r.message for r in caplog.records)


# ---------------------------------------------------------------------------
# FileWatcher wiring — guards the regression where ``mm web`` ran with no
# fs watcher at all. Files added to memory_dirs (whether while the server
# was up, or before the dir was registered) were never auto-picked-up
# until the user clicked Reindex. The lifespan now wires the same
# FileWatcher that ``server/context.py`` uses, gated on the same
# degraded-mode check.
# ---------------------------------------------------------------------------


async def test_lifespan_starts_and_stops_file_watcher():
    """Watcher started on lifespan entry, stopped on exit, exposed on
    ``app.state.file_watcher`` so routes / shutdown handlers can find it.
    """
    fake_watcher = MagicMock()
    fake_watcher.start = AsyncMock()
    fake_watcher.stop = AsyncMock()
    comp = _make_components(embedding_broken=None, stored_info=None)

    app = FastAPI()
    with (
        patch("memtomem.server.component_factory.create_components", AsyncMock(return_value=comp)),
        patch("memtomem.server.component_factory.close_components", AsyncMock()),
        patch("memtomem.search.dedup.DedupScanner", MagicMock()),
        patch("memtomem.indexing.watcher.FileWatcher", lambda *_a, **_kw: fake_watcher),
    ):
        async with _lifespan(app):
            assert fake_watcher.start.await_count == 1
            assert app.state.file_watcher is fake_watcher

    assert fake_watcher.stop.await_count == 1


async def test_lifespan_skips_watcher_in_degraded_mode():
    """When embedding is broken, the watcher must NOT start — the
    indexer would crash on the missing ``chunks_vec`` table. Recovery
    happens via ``mem_embedding_reset``; mirrors the same guard in
    ``server/context.py``.
    """
    fake_watcher = MagicMock()
    fake_watcher.start = AsyncMock()
    fake_watcher.stop = AsyncMock()
    comp = _make_components(
        embedding_broken={
            "dimension_mismatch": True,
            "model_mismatch": True,
            "stored": {"dimension": 0, "provider": "none", "model": ""},
            "configured": {"dimension": 1024, "provider": "onnx", "model": "bge-m3"},
        },
        stored_info={"dimension": 0, "provider": "none", "model": ""},
    )

    app = FastAPI()
    with (
        patch("memtomem.server.component_factory.create_components", AsyncMock(return_value=comp)),
        patch("memtomem.server.component_factory.close_components", AsyncMock()),
        patch("memtomem.search.dedup.DedupScanner", MagicMock()),
        patch("memtomem.indexing.watcher.FileWatcher", lambda *_a, **_kw: fake_watcher),
    ):
        async with _lifespan(app):
            pass

    assert fake_watcher.start.await_count == 0
    assert fake_watcher.stop.await_count == 0
    assert not hasattr(app.state, "file_watcher")
