import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest

from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeModelChanged
from nanobot.config.errors import ConfigLoadError
from nanobot.config.loader import save_config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.base import GenerationSettings
from nanobot.providers.factory import ProviderSnapshot, load_provider_snapshot
from nanobot.session.model_selection import (
    SESSION_MODEL_PRESET_METADATA_KEY,
    model_preset_from_metadata,
)
from nanobot.webui.settings_api import update_agent_settings


def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
    provider = MagicMock()
    provider.get_default_model.return_value = default_model
    provider.generation = SimpleNamespace(max_tokens=max_tokens)
    return provider


def test_provider_refresh_updates_only_runtime_resolver(tmp_path: Path) -> None:
    old_provider = _provider("old-model")
    new_provider = _provider("new-model", max_tokens=456)
    loop = AgentLoop(
        bus=MessageBus(),
        provider=old_provider,
        workspace=tmp_path,
        model="old-model",
        context_window_tokens=1000,
        provider_snapshot_loader=lambda: ProviderSnapshot(
            provider=new_provider,
            model="new-model",
            context_window_tokens=2000,
            signature=("new-model",),
        ),
    )
    loop.runtime_resolver.invalidate()

    runtime = loop.llm_runtime()

    assert runtime is loop.runtime_resolver.runtime
    assert loop.provider is new_provider
    assert loop.model == "new-model"
    assert loop.context_window_tokens == 2000
    assert not hasattr(loop.runner, "provider")
    assert not hasattr(loop.subagents, "provider")
    assert not hasattr(loop.subagents, "model")
    assert not hasattr(loop.subagents.runner, "provider")
    assert not hasattr(loop.consolidator, "provider")
    assert not hasattr(loop.consolidator, "model")
    assert not hasattr(loop.consolidator, "context_window_tokens")
    assert not hasattr(loop.consolidator, "max_completion_tokens")


def test_loop_has_no_mutable_runtime_mirrors_or_legacy_snapshot_api(tmp_path: Path) -> None:
    loop = AgentLoop(
        bus=MessageBus(),
        provider=_provider("test-model"),
        workspace=tmp_path,
        model="test-model",
        context_window_tokens=1000,
    )

    assert {
        "provider",
        "model",
        "context_window_tokens",
        "model_presets",
        "_active_preset",
        "_provider_signature",
        "_max_messages",
    }.isdisjoint(loop.__dict__)
    assert not hasattr(loop, "_apply_provider_snapshot")
    assert not hasattr(loop, "_build_model_preset_snapshot")


def test_llm_runtime_refreshes_provider_snapshot(tmp_path: Path) -> None:
    old_provider = _provider("old-model")
    new_provider = _provider("new-model", max_tokens=456)
    loop = AgentLoop(
        bus=MessageBus(),
        provider=old_provider,
        workspace=tmp_path,
        model="old-model",
        context_window_tokens=1000,
        provider_snapshot_loader=lambda: ProviderSnapshot(
            provider=new_provider,
            model="new-model",
            context_window_tokens=2000,
            signature=("new-model",),
        ),
    )
    loop.runtime_resolver.invalidate()

    runtime = loop.llm_runtime()

    assert runtime.provider is new_provider
    assert runtime.model == "new-model"
    assert loop.provider is new_provider
    assert not hasattr(loop.runner, "provider")


def test_llm_runtime_surfaces_invalidated_config_errors(tmp_path: Path) -> None:
    def fail_refresh() -> ProviderSnapshot:
        raise ValueError("invalid config")

    loop = AgentLoop(
        bus=MessageBus(),
        provider=_provider("old-model"),
        workspace=tmp_path,
        model="old-model",
        context_window_tokens=1000,
        provider_snapshot_loader=fail_refresh,
    )
    loop.runtime_resolver.invalidate()

    with pytest.raises(ValueError, match="invalid config"):
        loop.llm_runtime()


def test_provider_snapshot_missing_env_reports_explicit_config_path(
    tmp_path: Path,
    monkeypatch,
) -> None:
    name = "NANOBOT_TEST_REFRESH_MISSING_KEY"
    monkeypatch.delenv(name, raising=False)
    config_path = tmp_path / "custom.json"
    config_path.write_text(
        json.dumps({"providers": {"openrouter": {"apiKey": f"${{{name}}}"}}}),
        encoding="utf-8",
    )

    with pytest.raises(ConfigLoadError) as exc_info:
        load_provider_snapshot(config_path)

    assert exc_info.value.path == config_path


def test_same_snapshot_default_clears_preset_and_publishes_update(tmp_path: Path) -> None:
    base_provider = _provider("base-model")
    fast_provider = _provider("fast-model")
    fast_snapshot = ProviderSnapshot(
        provider=fast_provider,
        model="fast-model",
        context_window_tokens=2000,
        signature=("fast-model", "auto", "same-runtime"),
    )
    published: list[tuple[str, str | None]] = []
    loop = AgentLoop(
        bus=MessageBus(),
        provider=base_provider,
        workspace=tmp_path,
        model="base-model",
        context_window_tokens=1000,
        provider_signature=("base-model", "auto", "initial"),
        provider_snapshot_loader=lambda: fast_snapshot,
        model_presets={"fast": ModelPresetConfig(model="fast-model")},
        model_preset="fast",
        preset_snapshot_loader=lambda _name: fast_snapshot,
        runtime_model_publisher=lambda model, preset: published.append((model, preset)),
    )
    loop.runtime_resolver.invalidate()

    runtime = loop.llm_runtime()

    assert runtime.model_preset is None
    assert loop.model_preset is None
    assert published == [("fast-model", None)]


def test_named_default_refresh_is_used_by_sessions_without_override(tmp_path: Path) -> None:
    provider = _provider("shared-model")
    shared_signature = ("shared-model", "auto", "same-settings")
    snapshots = {
        "fast": ProviderSnapshot(
            provider=provider,
            model="shared-model",
            context_window_tokens=16_000,
            signature=shared_signature,
            model_preset="fast",
        ),
        "deep": ProviderSnapshot(
            provider=provider,
            model="shared-model",
            context_window_tokens=16_000,
            signature=shared_signature,
            model_preset="deep",
        ),
    }
    default_snapshot = snapshots["deep"]
    loop = AgentLoop(
        bus=MessageBus(),
        provider=provider,
        workspace=tmp_path,
        model="shared-model",
        context_window_tokens=16_000,
        provider_signature=shared_signature,
        provider_snapshot_loader=lambda: default_snapshot,
        model_presets={
            name: ModelPresetConfig(model=snapshot.model)
            for name, snapshot in snapshots.items()
        },
        model_preset="fast",
        preset_snapshot_loader=snapshots.__getitem__,
    )

    loop.runtime_resolver.invalidate()
    runtime = loop.llm_runtime()
    session = loop.sessions.get_or_create("sdk:new-after-refresh")

    assert runtime.model_preset == "deep"
    assert loop.runtime_for_session(session).model_preset == "deep"
    assert model_preset_from_metadata(session.metadata) is None


@pytest.mark.asyncio
async def test_config_invalidation_defers_canonical_notification_until_default_refresh(
    tmp_path: Path,
) -> None:
    provider = _provider("model-a")
    catalog = {"fast": ModelPresetConfig(model="model-a")}
    current_model = "model-a"
    published: list[RuntimeModelChanged] = []

    def load_preset(_name: str) -> ProviderSnapshot:
        return ProviderSnapshot(
            provider=provider,
            model=current_model,
            context_window_tokens=16_000,
            signature=(current_model, "auto"),
            model_preset="fast",
        )

    loop = AgentLoop(
        bus=MessageBus(),
        provider=provider,
        workspace=tmp_path,
        model="model-a",
        context_window_tokens=16_000,
        provider_signature=("model-a", "auto"),
        provider_snapshot_loader=lambda: load_preset("fast"),
        model_presets=catalog,
        preset_catalog_loader=lambda: catalog,
        model_preset="fast",
        preset_snapshot_loader=load_preset,
    )
    loop.runtime_events.subscribe(published.append, RuntimeModelChanged)
    session = loop.sessions.get_or_create("websocket:chat")
    session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "fast"
    current_model = "model-b"
    catalog["fast"] = ModelPresetConfig(model="model-b")

    loop.invalidate_runtime_config()
    runtime = loop.runtime_for_session(session)
    await asyncio.sleep(0)

    assert published == []
    assert runtime.model == "model-b"
    assert loop.model_presets["fast"].model == "model-b"

    assert loop.llm_runtime().model == "model-b"
    await asyncio.sleep(0)

    assert [(event.model, event.model_preset) for event in published] == [
        ("model-b", "fast"),
    ]


@pytest.mark.asyncio
async def test_config_refresh_publishes_renamed_canonical_preset(tmp_path: Path) -> None:
    provider = _provider("model-a")
    catalog = {"fast": ModelPresetConfig(model="model-a")}
    default_name = "fast"
    published: list[RuntimeModelChanged] = []

    def load_preset(name: str) -> ProviderSnapshot:
        return ProviderSnapshot(
            provider=provider,
            model=catalog[name].model,
            context_window_tokens=16_000,
            signature=(name, catalog[name].model),
            model_preset=name,
        )

    loop = AgentLoop(
        bus=MessageBus(),
        provider=provider,
        workspace=tmp_path,
        model="model-a",
        context_window_tokens=16_000,
        provider_signature=("fast", "model-a"),
        provider_snapshot_loader=lambda: load_preset(default_name),
        model_presets=catalog,
        preset_catalog_loader=lambda: catalog,
        model_preset="fast",
        preset_snapshot_loader=load_preset,
    )
    loop.runtime_events.subscribe(published.append, RuntimeModelChanged)
    catalog["Codex"] = catalog.pop("fast")
    default_name = "Codex"

    runtime = loop.refresh_runtime_config()
    await asyncio.sleep(0)

    assert (runtime.model, runtime.model_preset) == ("model-a", "Codex")
    assert [(event.model, event.model_preset) for event in published] == [
        ("model-a", "Codex"),
    ]


def test_next_turn_captures_generation_changed_after_previous_admission(
    tmp_path: Path,
) -> None:
    provider = _provider("test-model")
    provider.generation = GenerationSettings(temperature=0.2, max_tokens=1024)
    loop = AgentLoop(
        bus=MessageBus(),
        provider=provider,
        workspace=tmp_path,
        model="test-model",
        context_window_tokens=16_384,
    )

    first = loop.llm_runtime()
    provider.generation = GenerationSettings(temperature=0.8, max_tokens=512)
    second = loop.llm_runtime()

    assert first.generation.temperature == 0.2
    assert first.generation.max_tokens == 1024
    assert second.generation.temperature == 0.8
    assert second.generation.max_tokens == 512


def test_settings_context_window_refreshes_runtime_state(
    tmp_path: Path,
    monkeypatch,
) -> None:
    config_path = tmp_path / "config.json"
    config = Config()
    config.agents.defaults.workspace = str(tmp_path / "workspace")
    config.agents.defaults.model = "openai/gpt-4o"
    config.agents.defaults.provider = "openai"
    config.agents.defaults.context_window_tokens = 65_536
    config.providers.openai.api_key = "sk-test"
    save_config(config, config_path)
    monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)

    def loader(*, preset_name: str | None = None) -> ProviderSnapshot:
        return load_provider_snapshot(config_path, preset_name=preset_name)

    loop = AgentLoop.from_config(
        config,
        tool_registry=ToolRegistry(),
        provider_snapshot_loader=loader,
    )

    payload = update_agent_settings({"context_window_tokens": ["262144"]})
    loop.runtime_resolver.invalidate()
    loop.llm_runtime()

    assert payload["requires_restart"] is False
    assert loop.context_window_tokens == 262_144
    assert loop.llm_runtime().context_window_tokens == 262_144
