"""Regression coverage for #3256 / #3263: the global model.context_length cap
must apply ONLY when the session model equals model.default.

Bug: a global `model.context_length` (set for the default model, e.g. 232000)
was applied to EVERY model, silently shrinking a non-default model's real
context window (e.g. a 1M-context variant). The fix scopes the override to
`model.default` only, in `_resolve_context_length_for_session_model`.

This test drives the real resolver with a monkeypatched
`agent.model_metadata.get_model_context_length` that records the
`config_context_length` it receives, so we assert the gating without needing
the real agent metadata catalog.
"""
import sys
import types
from pathlib import Path as _Path
from unittest.mock import MagicMock, patch
from urllib.parse import urlparse


def _install_fake_get_model_context_length(monkeypatch, recorder, *, default_context=1_000_000):
    """Install a fake get_model_context_length into a stand-in agent.model_metadata."""
    mod = types.ModuleType("agent.model_metadata")

    def _fake(model, base_url="", api_key="", config_context_length=None, provider="", custom_providers=None):
        recorder["model"] = model
        recorder["base_url"] = base_url
        recorder["api_key"] = api_key
        recorder["provider"] = provider
        recorder["custom_providers"] = custom_providers
        recorder["config_context_length"] = config_context_length
        # Pretend the real per-model metadata window is 1,000,000 unless the
        # caller forced a config cap, in which case honor the cap (mirrors the
        # real helper's contract).
        if config_context_length:
            return int(config_context_length)
        return default_context

    mod.get_model_context_length = _fake
    # Ensure a parent `agent` package exists so `from agent.model_metadata import ...` resolves.
    if "agent" not in sys.modules:
        agent_pkg = types.ModuleType("agent")
        agent_pkg.__path__ = []
        monkeypatch.setitem(sys.modules, "agent", agent_pkg)
    monkeypatch.setitem(sys.modules, "agent.model_metadata", mod)


def _resolver():
    import api.routes as routes
    return routes._resolve_context_length_for_session_model


def test_global_cap_applies_to_default_model(monkeypatch):
    """When the session model IS model.default, the global cap is passed through."""
    import api.config as config
    rec = {}
    _install_fake_get_model_context_length(monkeypatch, rec)
    monkeypatch.setattr(
        config, "get_config",
        lambda *a, **k: {"model": {"default": "claude-opus-4.8", "context_length": 232000}},
    )
    result = _resolver()("claude-opus-4.8")
    assert rec["config_context_length"] == 232000, "default model must receive the global cap"
    assert result == 232000


def test_global_cap_NOT_applied_to_non_default_model(monkeypatch):
    """When the session model is NOT model.default, the global cap is dropped so
    the model's real (larger) window is used — the core #3256/#3263 fix."""
    import api.config as config
    rec = {}
    _install_fake_get_model_context_length(monkeypatch, rec)
    monkeypatch.setattr(
        config, "get_config",
        lambda *a, **k: {"model": {"default": "claude-opus-4.8", "context_length": 232000}},
    )
    result = _resolver()("claude-opus-4.7-1m-internal")
    assert rec["config_context_length"] is None, (
        "non-default model must NOT receive the global cap (it would clobber real metadata)"
    )
    assert result == 1_000_000, "non-default model should resolve to its real 1M window, not the 232K cap"


def test_no_default_configured_still_applies_cap(monkeypatch):
    """If model.default is unset, the cap applies (backward-compatible)."""
    import api.config as config
    rec = {}
    _install_fake_get_model_context_length(monkeypatch, rec)
    monkeypatch.setattr(
        config, "get_config",
        lambda *a, **k: {"model": {"context_length": 200000}},
    )
    result = _resolver()("some-model")
    assert rec["config_context_length"] == 200000
    assert result == 200000


def test_empty_model_returns_zero(monkeypatch):
    rec = {}
    _install_fake_get_model_context_length(monkeypatch, rec)
    assert _resolver()("") == 0
    assert _resolver()(None) == 0


# --- #3263 provider-aware default match (Codex final-gate finding, v0.51.192) ---
# model.default and the session model can be stored in equivalent-but-different
# shapes (bare / provider-prefixed / @provider:model). An exact string compare
# wrongly treats the actual default model as non-default and drops its configured
# context_length cap. _model_matches_configured_default() normalizes the shapes.

def _matcher():
    import api.routes as routes
    return routes._model_matches_configured_default


def test_default_match_exact():
    assert _matcher()("claude-opus-4.8", "claude-opus-4.8") is True


def test_default_match_bare_session_vs_prefixed_default():
    # Config default is provider-prefixed; session model is bare — still the default.
    assert _matcher()("claude-opus-4.8", "anthropic/claude-opus-4.8", "anthropic") is True


def test_default_match_prefixed_session_vs_bare_default():
    assert _matcher()("anthropic/claude-opus-4.8", "claude-opus-4.8", "anthropic") is True


def test_default_match_at_qualified_session():
    assert _matcher()("@anthropic:claude-opus-4.8", "claude-opus-4.8") is True


def test_default_match_distinct_models_do_not_match():
    assert _matcher()("claude-opus-4.7-1m", "claude-opus-4.8", "anthropic") is False
    assert _matcher()("claude-opus-4.7-1m", "anthropic/claude-opus-4.8", "anthropic") is False
    assert _matcher()("gpt-5.5", "claude-opus-4.8", "openai") is False


def test_default_match_same_bare_different_provider_does_NOT_match():
    """Same bare model id on a DIFFERENT provider is NOT the configured default
    and must not receive its cap (Codex final-gate over-match finding)."""
    assert _matcher()("openai/gpt-4o", "openrouter/gpt-4o", "openai") is False
    assert _matcher()("@openai:gpt-4o", "@openrouter:gpt-4o") is False
    assert _matcher()("gpt-4o", "openrouter/gpt-4o", "openai") is False
    # but the SAME provider (or unknown session provider) still matches:
    assert _matcher()("openrouter/gpt-4o", "openrouter/gpt-4o") is True
    assert _matcher()("gpt-4o", "openrouter/gpt-4o", "openrouter") is True


def test_default_match_empty_inputs_are_false():
    assert _matcher()("claude-opus-4.8", "") is False
    assert _matcher()("", "claude-opus-4.8") is False


def test_prefixed_default_still_receives_cap(monkeypatch):
    """The actual default model, configured provider-prefixed, must still get
    the global cap when the session stores it bare (the Codex final-gate bug)."""
    import api.config as config
    rec = {}
    _install_fake_get_model_context_length(monkeypatch, rec)
    monkeypatch.setattr(
        config, "get_config",
        lambda *a, **k: {"model": {"default": "anthropic/claude-opus-4.8", "context_length": 232000}},
    )
    # session model is the bare form of the prefixed default
    result = _resolver()("claude-opus-4.8", "anthropic")
    assert rec["config_context_length"] == 232000, (
        "provider-prefixed default model must still receive its configured cap"
    )
    assert result == 232000


def test_resolver_uses_explicit_reload_base_url_and_api_key(monkeypatch):
    """#4248: reload must query metadata with the same endpoint/key shape as streaming."""
    import api.config as config

    rec = {}
    _install_fake_get_model_context_length(monkeypatch, rec)
    monkeypatch.setattr(
        config,
        "get_config",
        lambda *a, **k: {
            "model": {
                "provider": "deepseek",
                "base_url": "https://config-base.invalid/v1",
                "api_key": "config-key",
            }
        },
    )

    result = _resolver()(
        "deepseek-v4-1m",
        "deepseek",
        base_url="https://runtime-base.invalid/v1",
        api_key="runtime-key",
    )

    assert result == 1_000_000
    assert rec["base_url"] == "https://runtime-base.invalid/v1"
    assert rec["api_key"] == "runtime-key"
    assert rec["provider"] == "deepseek"


def _stub_route_session(*, context_length=1_000_000, threshold_tokens=500_000, model="deepseek-v4-1m"):
    s = MagicMock()
    s.session_id = "test-4248"
    s.title = "test-session"
    s.workspace = "/tmp"
    s.model = model
    s.model_provider = "deepseek"
    s.profile = None
    s.messages = []
    s.tool_calls = []
    s.active_stream_id = None
    s.pending_user_message = None
    s.pending_attachments = []
    s.pending_started_at = None
    s.context_length = context_length
    s.threshold_tokens = threshold_tokens
    s.last_prompt_tokens = 100_000
    s.input_tokens = 100_000
    s.output_tokens = 0
    s.read_only = False
    s._loaded_metadata_only = False
    s.compact.return_value = {
        "session_id": s.session_id,
        "title": s.title,
        "workspace": s.workspace,
        "model": model,
        "model_provider": "deepseek",
        "message_count": 0,
        "input_tokens": s.input_tokens,
        "output_tokens": 0,
        "context_length": context_length,
        "threshold_tokens": threshold_tokens,
        "last_prompt_tokens": s.last_prompt_tokens,
    }
    return s


def test_session_reload_preserves_large_persisted_window_when_recompute_hits_256k_fallback(monkeypatch):
    """#4248: full session reload must not snap a persisted 1M window back to 256k."""
    import api.config as config
    import api.routes as routes

    captured = {}

    def fake_j(_handler, data, status=200):
        captured["data"] = data
        captured["status"] = status
        return True

    rec = {}
    _install_fake_get_model_context_length(monkeypatch, rec, default_context=256_000)
    monkeypatch.setattr(
        config,
        "get_config",
        lambda *a, **k: {
            "model": {
                "provider": "deepseek",
                "base_url": "https://config-base.invalid/v1",
                "api_key": "reload-key",
            }
        },
    )
    monkeypatch.setattr(
        config,
        "resolve_model_provider",
        lambda _model: ("deepseek-v4-1m", "deepseek", "https://runtime-base.invalid/v1"),
    )

    s = _stub_route_session()
    handler = MagicMock()
    parsed = urlparse("/api/session?session_id=test-4248&messages=1")

    with patch("api.routes.get_session", return_value=s), \
         patch("api.routes.j", side_effect=fake_j), \
         patch("api.routes._resolve_effective_session_model_for_display", return_value="deepseek-v4-1m"), \
         patch("api.routes._resolve_effective_session_model_provider_for_display", return_value="deepseek"), \
         patch("api.routes._session_visible_to_active_profile", return_value=True), \
         patch("api.routes._clear_stale_stream_state", return_value=None), \
         patch("api.routes._session_requires_cli_metadata_lookup", return_value=False), \
         patch("api.routes._is_messaging_session_record", return_value=False), \
         patch("api.routes.get_state_db_session_messages", return_value=[]), \
         patch("api.routes._webui_sidecar_lineage_messages_for_display", return_value=[]), \
         patch("api.routes.merge_session_messages_append_only", return_value=[]), \
         patch("api.routes._merged_webui_lineage_messages_for_display", return_value=[]), \
         patch("api.routes._active_stream_ids", return_value=set()):
        assert routes.handle_get(handler, parsed) is True

    body = captured["data"]["session"]
    assert captured["status"] == 200
    assert rec["base_url"] == "https://runtime-base.invalid/v1"
    assert rec["api_key"] == "reload-key"
    assert body["context_length"] == 1_000_000, (
        "reload must keep the persisted 1M window instead of clobbering it "
        "with the anonymous 256k fallback"
    )
    assert body["threshold_tokens"] == 500_000, (
        "the existing threshold belongs to the preserved 1M window and must not "
        "be cleared when the 256k recompute is rejected"
    )


def test_session_reload_preserves_large_window_for_slash_qualified_model(monkeypatch):
    """#4248 follow-up (Codex regression gate): a slash-qualified stored model
    (``deepseek/deepseek-v4-1m``, OpenRouter-style) that resolves to its bare id
    must NOT be treated as a model change, or the 256k fallback bypasses the
    accept-guard and clobbers the persisted 1M window.
    """
    import api.config as config
    import api.routes as routes

    captured = {}

    def fake_j(_handler, data, status=200):
        captured["data"] = data
        captured["status"] = status
        return True

    rec = {}
    _install_fake_get_model_context_length(monkeypatch, rec, default_context=256_000)
    monkeypatch.setattr(
        config,
        "get_config",
        lambda *a, **k: {
            "model": {
                "provider": "deepseek",
                "base_url": "https://config-base.invalid/v1",
                "api_key": "reload-key",
            }
        },
    )
    # Resolver returns the BARE model id (slash prefix stripped) — exactly the
    # shape that made `_session_model_identity_matches` report a false change.
    monkeypatch.setattr(
        config,
        "resolve_model_provider",
        lambda _model: ("deepseek-v4-1m", "deepseek", "https://runtime-base.invalid/v1"),
    )

    s = _stub_route_session(model="deepseek/deepseek-v4-1m")
    handler = MagicMock()
    parsed = urlparse("/api/session?session_id=test-4248-slash&messages=1")

    with patch("api.routes.get_session", return_value=s), \
         patch("api.routes.j", side_effect=fake_j), \
         patch("api.routes._resolve_effective_session_model_for_display", return_value="deepseek/deepseek-v4-1m"), \
         patch("api.routes._resolve_effective_session_model_provider_for_display", return_value="deepseek"), \
         patch("api.routes._session_visible_to_active_profile", return_value=True), \
         patch("api.routes._clear_stale_stream_state", return_value=None), \
         patch("api.routes._session_requires_cli_metadata_lookup", return_value=False), \
         patch("api.routes._is_messaging_session_record", return_value=False), \
         patch("api.routes.get_state_db_session_messages", return_value=[]), \
         patch("api.routes._webui_sidecar_lineage_messages_for_display", return_value=[]), \
         patch("api.routes.merge_session_messages_append_only", return_value=[]), \
         patch("api.routes._merged_webui_lineage_messages_for_display", return_value=[]), \
         patch("api.routes._active_stream_ids", return_value=set()):
        assert routes.handle_get(handler, parsed) is True

    body = captured["data"]["session"]
    assert captured["status"] == 200
    assert body["context_length"] == 1_000_000, (
        "a slash-qualified stored model resolving to its bare id is the SAME "
        "model, so the 256k fallback must not clobber the persisted 1M window"
    )
    assert body["threshold_tokens"] == 500_000


def test_session_model_identity_matches_slash_qualified_form():
    """Unit: the identity check treats provider/model and bare-model as equal."""
    import api.routes as routes

    # slash-qualified stored vs bare resolved, same provider → same model
    assert routes._session_model_identity_matches(
        "deepseek/deepseek-v4-1m", "deepseek", "deepseek-v4-1m", "deepseek"
    ) is True
    # @provider:model form still resolves (no regression)
    assert routes._session_model_identity_matches(
        "@deepseek:deepseek-v4-1m", "deepseek", "deepseek-v4-1m", "deepseek"
    ) is True
    # different provider on the slash prefix → NOT the same model
    assert routes._session_model_identity_matches(
        "deepseek/deepseek-v4-1m", "deepseek", "deepseek-v4-1m", "openai"
    ) is False
    # genuinely different bare model → not a match
    assert routes._session_model_identity_matches(
        "deepseek/deepseek-v4-1m", "deepseek", "gpt-4o", "openai"
    ) is False


def test_session_reload_accepts_real_256k_when_effective_model_changes(monkeypatch):
    """#4248 follow-up: the 256k fallback guard must not block a real model switch."""
    import api.config as config
    import api.routes as routes

    captured = {}

    def fake_j(_handler, data, status=200):
        captured["data"] = data
        captured["status"] = status
        return True

    rec = {}
    _install_fake_get_model_context_length(monkeypatch, rec, default_context=256_000)
    monkeypatch.setattr(
        config,
        "get_config",
        lambda *a, **k: {
            "model": {
                "provider": "deepseek",
                "base_url": "https://runtime-base.invalid/v1",
                "api_key": "reload-key",
            }
        },
    )
    monkeypatch.setattr(
        config,
        "resolve_model_provider",
        lambda _model: ("deepseek-v4-256k", "deepseek", "https://runtime-base.invalid/v1"),
    )

    s = _stub_route_session(model="deepseek-v4-1m")
    handler = MagicMock()
    parsed = urlparse("/api/session?session_id=test-4248&messages=1")

    with patch("api.routes.get_session", return_value=s), \
         patch("api.routes.j", side_effect=fake_j), \
         patch("api.routes._resolve_effective_session_model_for_display", return_value="deepseek-v4-256k"), \
         patch("api.routes._resolve_effective_session_model_provider_for_display", return_value="deepseek"), \
         patch("api.routes._session_visible_to_active_profile", return_value=True), \
         patch("api.routes._clear_stale_stream_state", return_value=None), \
         patch("api.routes._session_requires_cli_metadata_lookup", return_value=False), \
         patch("api.routes._is_messaging_session_record", return_value=False), \
         patch("api.routes.get_state_db_session_messages", return_value=[]), \
         patch("api.routes._webui_sidecar_lineage_messages_for_display", return_value=[]), \
         patch("api.routes.merge_session_messages_append_only", return_value=[]), \
         patch("api.routes._merged_webui_lineage_messages_for_display", return_value=[]), \
         patch("api.routes._active_stream_ids", return_value=set()):
        assert routes.handle_get(handler, parsed) is True

    body = captured["data"]["session"]
    assert captured["status"] == 200
    assert rec["model"] == "deepseek-v4-256k"
    assert body["context_length"] == 256_000, (
        "a genuine effective-model change to a 256k model must replace the "
        "old 1M snapshot rather than being treated as an anonymous fallback"
    )
    assert body["threshold_tokens"] == 128_000


def test_session_context_lookup_keeps_base_url_when_custom_helper_is_missing(monkeypatch):
    """#4248 follow-up: non-custom base URL resolution must not depend on custom helper imports."""
    import api.config as config
    import api.routes as routes

    monkeypatch.setattr(
        config,
        "resolve_model_provider",
        lambda _model: ("deepseek-v4-1m", "deepseek", "https://runtime-base.invalid/v1"),
    )
    monkeypatch.delattr(config, "resolve_custom_provider_connection", raising=False)

    model, provider, base_url, api_key = routes._session_context_length_lookup_state(
        "deepseek-v4-1m",
        "deepseek",
    )

    assert model == "deepseek-v4-1m"
    assert provider == "deepseek"
    assert base_url == "https://runtime-base.invalid/v1"
    assert api_key == ""


# --- #3263 dual-gate MUST-FIX invariants (Codex regression gate, v0.51.192) ---
# These pin the two consistency fixes applied after the gate found that the
# default-only guard dropped the stale cap but didn't (a) recompute a persisted
# stale context_length, or (b) rescale the terminal SSE threshold. Both live
# deep inside _run_agent_streaming, so we pin them at the source-structure level
# (the live-snapshot path already had behavioral coverage; these guard the two
# sibling paths from silently regressing back to the stale value).
_STREAMING_SRC = (_Path(__file__).resolve().parent.parent / "api" / "streaming.py").read_text(encoding="utf-8")


def test_persistence_fallback_also_runs_when_skip_cc_cl():
    """The per-turn persistence fallback must recompute the real cap when the
    stale compressor cap was skipped — not only when context_length is falsy.
    Otherwise a previously-persisted stale 232K survives forever."""
    assert "(not getattr(s, 'context_length', 0)) or _skip_cc_cl:" in _STREAMING_SRC, (
        "persistence fallback gate must also fire on _skip_cc_cl (#3263 MUST-FIX 1)"
    )


def test_persistence_rescales_threshold_when_cap_skipped():
    """When the stale cap is skipped and the real cap recomputed, the persisted
    threshold_tokens must be rescaled to the real cap (or cleared), so a reload
    matches the live snapshot."""
    assert "if _skip_cc_cl:" in _STREAMING_SRC
    assert "s.threshold_tokens = int(_orig_thresh * _real_cap / _orig_cap)" in _STREAMING_SRC, (
        "persistence path must rescale threshold_tokens to the real cap (#3263 MUST-FIX 2)"
    )


def test_sse_done_payload_rescales_threshold_when_cap_dropped():
    """The terminal SSE usage payload must rescale threshold_tokens when it
    dropped the stale compressor cap, so the indicator doesn't revert on stream
    end (messages.js overwrites S.lastUsage with this payload)."""
    assert "_dropped_stale_cap_sse" in _STREAMING_SRC
    assert "usage['threshold_tokens'] = int(_orig_cc_thresh_sse * _fb_cl / _orig_cc_cl_sse)" in _STREAMING_SRC, (
        "SSE done payload must rescale threshold_tokens to the resolved window (#3263 MUST-FIX 3)"
    )


def test_live_snapshot_guard_broadened_to_any_model_window_mismatch():
    """#4618: the live-usage snapshot stale-compressor guard must correct the
    cached window whenever the resolved real per-model window DIFFERS from the
    compressor's cached value — not only when it exactly equals the global cap.

    A leftover *other-model* cached value (e.g. opus-4.5's 168k lingering on an
    opus-4.8 1M session after an in-place model switch) is != the config cap, so
    the old `_cc_cl_u == _cl_u` condition let it slip through to the live payload
    ('refresh shows 1M, send-a-message reverts to 168k'). Pin the broadened
    condition so it cannot silently revert to the narrow default-only form."""
    assert "_real_u and _real_u != _cc_cl_u" in _STREAMING_SRC, (
        "live-snapshot guard must correct on ANY real-window != cached-window "
        "mismatch (#4618), not only the default-model exact-cap case"
    )
    # The narrow default-only signals must be GONE from the live-snapshot block.
    assert "_model_matches_configured_default as _mmcd_u" not in _STREAMING_SRC, (
        "the old default-only guard (_mmcd_u) must be removed from the "
        "live-snapshot path (#4618 broadened it)"
    )


def test_live_snapshot_reuses_hydration_context_length_helper():
    """#4618: the broadened guard must resolve the real window through the SAME
    helper GET /api/session hydration uses (_context_length_lookup_inputs_for_model
    + get_model_context_length) so the streaming/SSE path and the hydration path
    land on an IDENTICAL value (honors nested per-model config overrides + custom
    providers). Pin the helper reuse against a hand-rolled flat-cap regression."""
    assert "_context_length_lookup_inputs_for_model as _cli_u" in _STREAMING_SRC, (
        "live-snapshot guard must reuse the hydration lookup-inputs helper (#4618)"
    )
    assert "from agent.model_metadata import get_model_context_length as _g_u" in _STREAMING_SRC


def test_live_snapshot_guard_has_legacy_two_arg_fallback():
    """#4618: a TypeError from the modern 6-arg get_model_context_length (older
    hermes-agent builds) must fall back to the legacy 2-arg form rather than
    raising, and the fallback must apply the SAME mismatch condition."""
    assert "from agent.model_metadata import get_model_context_length as _g2_u" in _STREAMING_SRC
    assert "_real_u = _g2_u(_sm_u, _base_u) or 0" in _STREAMING_SRC, (
        "live-snapshot guard must keep a legacy 2-arg get_model_context_length "
        "fallback for older hermes-agent builds (#4618)"
    )


def test_live_snapshot_resolves_session_profile_config_not_ambient():
    """#4618 gate fix (Codex): the streaming worker is a detached thread that
    does NOT inherit the per-request thread-local profile context, so a bare
    get_config() resolves the process-global (default) profile (#3294). For a
    non-default profile pinning a different per-model context_length, that would
    surface the WRONG profile's window in the live payload. The guard must
    resolve the SESSION's own profile config via get_config_for_profile_home."""
    assert "from api.config import get_config_for_profile_home as _gch_u" in _STREAMING_SRC, (
        "live-snapshot guard must read the session's profile config, not ambient "
        "get_config() (#3294 cross-profile leak class)"
    )
    assert "get_hermes_home_for_profile as _ghp_u" in _STREAMING_SRC
    assert "_ghp_u(getattr(_session_obj, 'profile', None))" in _STREAMING_SRC, (
        "profile home must derive from the live session object's profile"
    )


def test_live_snapshot_guard_honors_256k_clobber_acceptance_gate():
    """#4618 gate fix (Opus): the broadened guard aligned resolution with
    hydration but must ALSO honor hydration's #4248 acceptance gate — a transient
    low-confidence 256k metadata fallback must NOT clobber a LARGER cached window
    mid-stream (which would reintroduce the 'drops to a smaller window' regression
    this guard exists to fix). Reuse the exact hydration acceptance helper on both
    the modern and legacy paths."""
    assert "_should_accept_session_context_length_refresh as _accept_u" in _STREAMING_SRC, (
        "live-snapshot guard must reuse the #4248 acceptance gate "
        "(_should_accept_session_context_length_refresh)"
    )
    assert "and _accept_u(_cc_cl_u, _real_u)" in _STREAMING_SRC, (
        "the correction must be gated by the 256k-clobber acceptance check (#4248)"
    )
    # Legacy 2-arg fallback must apply the same gate.
    assert "_should_accept_session_context_length_refresh as _accept2_u" in _STREAMING_SRC
    assert "and _accept2_u(_cc_cl_u, _real_u)" in _STREAMING_SRC, (
        "the legacy 2-arg fallback must also honor the 256k acceptance gate"
    )


def test_session_save_path_broadened_to_any_model_window_mismatch():
    """#4618 (Codex re-gate finding 1): the FINAL session-save stale-compressor
    guard must also broaden beyond the default-only exact-cap test — otherwise a
    leftover other-model window (168k) is PERSISTED to s.context_length and a
    reload shows the wrong window. It must resolve the real window via the same
    helper and honor the #4248 acceptance gate before skipping the compressor
    value."""
    assert "_context_length_lookup_inputs_for_model as _cli_cc" in _STREAMING_SRC, (
        "save path must resolve the real per-model window via the hydration helper (#4618)"
    )
    assert "_should_accept_session_context_length_refresh as _accept_cc" in _STREAMING_SRC
    assert "if _real_cc and _real_cc != _cc_cl and _accept_cc(_cc_cl, _real_cc):" in _STREAMING_SRC, (
        "save path must skip the compressor value on ANY accepted real-window "
        "mismatch, not only the default-model exact-cap case (#4618)"
    )


def test_sse_done_path_broadened_to_any_model_window_mismatch():
    """#4618 (Codex re-gate finding 2): the terminal `done` SSE usage path must
    also broaden — otherwise the indicator REVERTS to the stale window on stream
    end (messages.js overwrites S.lastUsage with the done payload). It must
    resolve the real window via the same helper and honor the #4248 gate before
    dropping the compressor value."""
    assert "_context_length_lookup_inputs_for_model as _cli_sse" in _STREAMING_SRC, (
        "SSE done path must resolve the real per-model window via the hydration helper (#4618)"
    )
    assert "_should_accept_session_context_length_refresh as _accept_sse" in _STREAMING_SRC
    assert "if _real_sse and _real_sse != _cc_cl_sse and _accept_sse(_cc_cl_sse, _real_sse):" in _STREAMING_SRC, (
        "SSE done path must drop the compressor value on ANY accepted real-window "
        "mismatch so the indicator can't snap back to a stale window at turn-end (#4618)"
    )
    # The old narrow default-only matcher must be gone from BOTH sibling paths.
    assert "_model_matches_configured_default as _mmcd_sse" not in _STREAMING_SRC, (
        "the old default-only SSE matcher (_mmcd_sse) must be removed (#4618)"
    )
    assert "_model_matches_configured_default as _mmcd_cc" not in _STREAMING_SRC, (
        "the old default-only save matcher (_mmcd_cc) must be removed (#4618)"
    )


# --- #7535: provider-ownership guard on the global model.base_url backfill ---
# Same family as #3256: a global value scoped to its owner must not leak onto
# sessions owned by a different provider.


def test_builtin_provider_lookup_does_not_inherit_other_providers_base_url():
    """#7535: a session on a built-in registry provider (whose base_url is empty
    by design because the endpoint lives in PROVIDER_REGISTRY, not config.yaml)
    must NOT inherit the global ``model.base_url`` owned by a different (custom)
    provider. The unguarded backfill handed the custom endpoint to the
    context-length resolver, which probed the wrong ``/models`` catalog and fell
    back to the generic 128K catalog entry for a 1M-context model, while the
    on-disk session and SSE stream both carried 1,000,000."""
    from api.routes import _context_length_lookup_inputs_for_model

    ark_base = "https://ark.cn-beijing.volces.com/api/coding/v3"
    cfg = {
        "model": {
            "provider": "custom:ark",
            "base_url": ark_base,
            "default": "ark-code-latest",
            "context_length": 1000000,
        },
        "custom_providers": [
            {
                "name": "ark",
                "base_url": ark_base,
                "api_key": "ark-key",
                "model": "ark-code-latest",
            }
        ],
    }

    lookup = _context_length_lookup_inputs_for_model(
        "@opencode-go:deepseek-v4.1-flash",
        "opencode-go",
        cfg=cfg,
    )

    assert lookup.provider == "opencode-go"
    assert lookup.base_url == "", (
        "the global model.base_url belongs to custom:ark; the opencode-go "
        "registry slot must stay empty so PROVIDER_REGISTRY's endpoint "
        "resolves the context window (#7535)"
    )


def test_same_provider_session_keeps_global_base_url_backfill():
    """#7535 control (compat): when the session provider IS the configured
    owner of ``model.base_url``, the backfill behavior is preserved."""
    from api.routes import _context_length_lookup_inputs_for_model

    ark_base = "https://ark.cn-beijing.volces.com/api/coding/v3"
    cfg = {
        "model": {
            "provider": "custom:ark",
            "base_url": ark_base,
            "default": "ark-code-latest",
        },
    }

    lookup = _context_length_lookup_inputs_for_model(
        "ark-code-latest",
        "custom:ark",
        cfg=cfg,
    )

    assert lookup.provider == "custom:ark"
    assert lookup.base_url == ark_base, (
        "same-owner sessions must keep the model.base_url backfill (#7535 compat)"
    )


def test_base_url_only_config_without_provider_keeps_endpoint():
    """Regression (PR #7743 gate): a ``model`` section that declares
    ``base_url`` but NO ``provider`` has no owner that could conflict, so any
    session must still receive the URL — exactly as master did. The profile
    setup path writes this shape (api/profiles.py), and dropping the URL made
    the lookup skip the endpoint and silently fall back to 256,000."""
    from api.routes import _context_length_lookup_inputs_for_model

    only_base_url = "http://127.0.0.1:9/v1"
    cfg = {
        "model": {
            "base_url": only_base_url,
            "default": "some-model",
        },
    }

    for session_provider in ("custom", "openrouter"):
        lookup = _context_length_lookup_inputs_for_model(
            "some-model",
            session_provider,
            cfg=cfg,
        )

        assert lookup.provider == session_provider
        assert lookup.base_url == only_base_url, (
            f"a config with no declared provider owner cannot conflict with the "
            f"{session_provider!r} session, so the base_url must survive the "
            f"ownership guard"
        )


def test_underscore_provider_owner_matches_hyphen_session():
    """Regression (PR #7743 gate): ``opencode_go`` and ``opencode-go`` are two
    spellings of the SAME built-in provider, so the owner session must still
    receive ``model.base_url``. Comparing the ids through the repo's
    canonicaliser folds the underscore without collapsing distinct
    ``custom:*`` slugs."""
    from api.routes import _context_length_lookup_inputs_for_model

    owner_base = "http://127.0.0.1:9/v1"
    cfg = {
        "model": {
            "provider": "opencode_go",
            "base_url": owner_base,
            "default": "some-model",
        },
    }

    lookup = _context_length_lookup_inputs_for_model(
        "some-model",
        "opencode-go",
        cfg=cfg,
    )

    assert lookup.provider == "opencode-go"
    assert lookup.base_url == owner_base, (
        "opencode_go and opencode-go are the same built-in provider, so the "
        "owner session must keep the base_url backfill"
    )


def test_underscore_provider_still_blocks_distinct_custom_owner():
    """Counterpart to test_underscore_provider_owner_matches_hyphen_session:
    folding underscores must not open the #7535 guard. ``custom:my_box`` and a
    session on the unrelated built-in ``opencode-go`` are still different
    providers, so the custom owner's URL stays out of the registry slot."""
    from api.routes import _context_length_lookup_inputs_for_model

    cfg = {
        "model": {
            "provider": "custom:my_box",
            "base_url": "http://127.0.0.1:9/v1",
            "default": "some-model",
        },
    }

    lookup = _context_length_lookup_inputs_for_model(
        "some-model",
        "opencode-go",
        cfg=cfg,
    )

    assert lookup.provider == "opencode-go"
    assert lookup.base_url == "", (
        "underscore folding must not let a distinct custom provider's "
        "base_url leak onto another provider's session (#7535 guard)"
    )
