"""Regression tests for RCA t_16551f61: auth.json ~14-min token churn must
NOT bust the 24h /api/models cache, but a real provider/model-set change
still must.

Bug shape (chained from RCA t_d127953d residual #2): the models cache has a
24h TTL, but its _source_fingerprint hashed auth.json via mtime/size
(#1699's _models_cache_file_fingerprint). credential-pool / OAuth token
refresh rewrites the WHOLE auth.json roughly every 14 minutes (RCA observed
17:12:51 -> 17:26:31), bumping mtime+size every time. So the fingerprint
mismatched every ~14 min, the 24h cache was effectively dead, and the hot
GET /api/session?resolve_model=1 path paid a cold ~11.5s rebuild over and
over.

Fix: fingerprint auth.json by *content* with the credential-rotation fields
deny-listed (_auth_store_semantic_fingerprint). Token rotation is invisible
to the fingerprint; anything that changes the available provider/model set
still changes it.

These tests are INVARIANTS, not change-detectors:
  * churn-immune  — rewriting only volatile token fields keeps the
                     fingerprint byte-identical AND keeps a valid disk cache
                     loadable.
  * real-change-invalidates — active_provider change, a NEW credential_pool
                     entry, and a changed base_url each flip the fingerprint
                     AND reject the previously-valid disk cache.

A test that only asserted "fingerprint stable on churn" would pass even if
the function hard-coded `return {}` — hence the paired real-change half and
the explicit assertion that the two fingerprints actually differ on a real
change. Verified non-tautological by reverting the fix (see report.md): the
churn-immune half then fails.
"""

import json
import time

import api.config as config


# ── auth.json builders ────────────────────────────────────────────────────


def _auth_store(*, active_provider="anthropic", extra_pool_provider=None,
                anthropic_base_url="https://api.anthropic.com",
                access_token="tok-AAAA", request_count=1):
    """A realistic auth.json shaped like the production researcher profile
    (version / providers / active_provider / credential_pool / updated_at)."""
    pool = {
        "anthropic": [
            {
                "id": "anthropic-oauth-1",
                "label": "Claude Max",
                "auth_type": "oauth",
                "priority": 0,
                "source": "claude-oauth",
                "access_token": access_token,
                "refresh_token": "refresh-" + access_token,
                "last_status": "ok",
                "last_status_at": None,
                "last_error_code": None,
                "expires_at_ms": 1779152637000,
                "base_url": anthropic_base_url,
                "request_count": request_count,
            }
        ],
        "openrouter": [
            {
                "id": "openrouter-key-1",
                "label": "OpenRouter",
                "auth_type": "api_key",
                "priority": 1,
                "source": "env",
                "access_token": "or-" + access_token,
                "last_status": "ok",
                "base_url": "https://openrouter.ai/api/v1",
                "request_count": request_count,
            }
        ],
    }
    if extra_pool_provider:
        pool[extra_pool_provider] = [
            {
                "id": f"{extra_pool_provider}-key-1",
                "label": extra_pool_provider.title(),
                "auth_type": "api_key",
                "priority": 2,
                "source": "manual",
                "access_token": "new-provider-token",
                "base_url": f"https://api.{extra_pool_provider}.test/v1",
                "request_count": 0,
            }
        ]
    return {
        "version": 2,
        "providers": {},
        "active_provider": active_provider,
        "credential_pool": pool,
        "updated_at": "2026-05-19T01:00:00+00:00",
    }


def _write_auth(tmp_path, monkeypatch, store: dict):
    """Point _get_auth_store_path() at a tmp auth.json and write `store`.

    Writes `store` as JSON to a tmp auth.json and monkeypatches
    config._get_auth_store_path() to return that path. The mtime/size
    sleep+restat dance lives at the call sites (the tests that actually
    need to prove the OLD stat-based fingerprint WOULD have churned);
    this helper itself only does the write + monkeypatch.
    """
    auth_path = tmp_path / "auth.json"
    auth_path.write_text(json.dumps(store, indent=2), encoding="utf-8")
    monkeypatch.setattr(config, "_get_auth_store_path", lambda: auth_path)
    return auth_path


# ── churn-immune invariant ─────────────────────────────────────────────────


def test_token_only_churn_keeps_auth_fingerprint_identical(tmp_path, monkeypatch):
    """Rotating ONLY the volatile credential fields (access/refresh token,
    expiry, status, request_count, updated_at) must not change the auth.json
    fingerprint at all — even though mtime_ns and size both change."""
    p = _write_auth(tmp_path, monkeypatch, _auth_store(access_token="tok-AAAA",
                                                       request_count=1))
    st1 = p.stat()
    fp1 = config._auth_store_semantic_fingerprint(p)

    time.sleep(0.01)
    # Same logical config, fresh tokens + bumped counters + new save ts.
    bigger = _auth_store(access_token="tok-BBBBBBBBBBBBBBBBBBBB",
                          request_count=999)
    bigger["updated_at"] = "2026-05-19T01:14:00+00:00"
    p.write_text(json.dumps(bigger, indent=2), encoding="utf-8")
    st2 = p.stat()
    fp2 = config._auth_store_semantic_fingerprint(p)

    # Precondition: the OLD stat-based fingerprint WOULD have churned.
    assert (st1.st_mtime_ns, st1.st_size) != (st2.st_mtime_ns, st2.st_size)
    # Invariant: the content fingerprint does NOT churn.
    assert fp1 == fp2
    assert "semantic_sha256" in fp1


def test_token_churn_does_not_reject_valid_disk_models_cache(tmp_path, monkeypatch):
    """End-to-end: a disk models cache saved before a token refresh stays
    loadable after the refresh (the actual user-visible bug — cold rebuild
    every ~14 min)."""
    _write_auth(tmp_path, monkeypatch, _auth_store(access_token="tok-1"))
    monkeypatch.setattr(config, "_models_cache_path",
                        tmp_path / "models_cache.json")

    shape = {
        "active_provider": "anthropic",
        "default_model": "claude-opus-4-7",
        "configured_model_badges": {"claude-opus-4-7": "Anthropic"},
        "groups": [{"name": "Anthropic", "models": ["claude-opus-4-7"]}],
    }
    config._save_models_cache_to_disk(shape)
    assert config._load_models_cache_from_disk() is not None

    # ~14 minutes later: credential-pool refresh rewrites auth.json.
    time.sleep(0.01)
    churned = _auth_store(access_token="tok-2-rotated", request_count=42)
    churned["updated_at"] = "2026-05-19T01:14:00+00:00"
    config._get_auth_store_path().write_text(
        json.dumps(churned, indent=2), encoding="utf-8")

    assert config._load_models_cache_from_disk() is not None, (
        "Pure token-refresh churn of auth.json must NOT invalidate the 24h "
        "models cache (RCA t_16551f61)"
    )


# ── real-change-invalidates invariant ──────────────────────────────────────


def test_active_provider_change_changes_auth_fingerprint(tmp_path, monkeypatch):
    p = _write_auth(tmp_path, monkeypatch, _auth_store(active_provider="anthropic"))
    fp_before = config._auth_store_semantic_fingerprint(p)

    p.write_text(json.dumps(_auth_store(active_provider="openrouter"), indent=2),
                 encoding="utf-8")
    fp_after = config._auth_store_semantic_fingerprint(p)

    assert fp_before != fp_after, (
        "Changing active_provider changes the available-provider set and MUST "
        "bust the cache"
    )


def test_new_credential_pool_entry_changes_auth_fingerprint(tmp_path, monkeypatch):
    """The explicit edge from the task body: adding a credential_pool entry
    that enables a NEW provider MUST still invalidate the cache."""
    p = _write_auth(tmp_path, monkeypatch, _auth_store())
    fp_before = config._auth_store_semantic_fingerprint(p)

    p.write_text(json.dumps(_auth_store(extra_pool_provider="deepseek"),
                            indent=2), encoding="utf-8")
    fp_after = config._auth_store_semantic_fingerprint(p)

    assert fp_before != fp_after, (
        "A new credential_pool provider entry expands the model set and MUST "
        "bust the cache (task t_16551f61 boundary case)"
    )


def test_changed_base_url_changes_auth_fingerprint(tmp_path, monkeypatch):
    """Endpoint / api-base is a non-volatile field — it stays in the
    fingerprint, so pointing a provider at a different endpoint invalidates."""
    p = _write_auth(tmp_path, monkeypatch, _auth_store())
    fp_before = config._auth_store_semantic_fingerprint(p)

    p.write_text(
        json.dumps(_auth_store(anthropic_base_url="https://proxy.internal/v1"),
                   indent=2),
        encoding="utf-8")
    fp_after = config._auth_store_semantic_fingerprint(p)

    assert fp_before != fp_after, "A changed provider base_url MUST bust the cache"


def test_real_change_rejects_previously_valid_disk_cache(tmp_path, monkeypatch):
    """End-to-end mirror of the churn test: a disk cache that was valid must
    be REJECTED once a genuine provider-set change lands in auth.json."""
    _write_auth(tmp_path, monkeypatch, _auth_store(active_provider="anthropic"))
    monkeypatch.setattr(config, "_models_cache_path",
                        tmp_path / "models_cache.json")
    config._save_models_cache_to_disk({
        "active_provider": "anthropic",
        "default_model": "claude-opus-4-7",
        "configured_model_badges": {"claude-opus-4-7": "Anthropic"},
        "groups": [{"name": "Anthropic", "models": ["claude-opus-4-7"]}],
    })
    assert config._load_models_cache_from_disk() is not None

    config._get_auth_store_path().write_text(
        json.dumps(_auth_store(active_provider="openrouter"), indent=2),
        encoding="utf-8")

    assert config._load_models_cache_from_disk() is None, (
        "A real active_provider change MUST reject the stale disk cache — the "
        "fix must not over-stabilise into serving wrong data"
    )


# ── deny-list helper unit guards ───────────────────────────────────────────


def test_strip_volatile_is_pure_and_recurses():
    src = {
        "active_provider": "anthropic",
        "credential_pool": {
            "anthropic": [
                {"id": "x", "base_url": "u", "access_token": "S",
                 "request_count": 7, "last_status": "ok"}
            ]
        },
        "updated_at": "ts",
    }
    import copy as _copy
    snapshot = _copy.deepcopy(src)
    out = config._strip_volatile_auth_fields(src)

    assert src == snapshot  # input untouched (pure)
    entry = out["credential_pool"]["anthropic"][0]
    assert entry == {"id": "x", "base_url": "u"}  # volatile keys gone, rest kept
    assert "updated_at" not in out
    assert out["active_provider"] == "anthropic"  # non-volatile preserved


def test_unknown_field_stays_in_fingerprint(tmp_path, monkeypatch):
    """Deny-list (not allow-list) safety: a field we don't know about is NOT
    stripped, so a hypothetical future provider-gating field still busts the
    cache."""
    base = _auth_store()
    base["some_future_provider_gate"] = "v1"
    p = _write_auth(tmp_path, monkeypatch, base)
    fp1 = config._auth_store_semantic_fingerprint(p)

    base["some_future_provider_gate"] = "v2"
    p.write_text(json.dumps(base, indent=2), encoding="utf-8")
    fp2 = config._auth_store_semantic_fingerprint(p)

    assert fp1 != fp2, (
        "An unknown (non-deny-listed) field must remain in the fingerprint — "
        "the deny-list must fail safe toward over-invalidation"
    )


def test_missing_auth_file_fingerprint_is_stable_and_marked(tmp_path, monkeypatch):
    missing = tmp_path / "nope" / "auth.json"
    monkeypatch.setattr(config, "_get_auth_store_path", lambda: missing)
    fp = config._auth_store_semantic_fingerprint(missing)
    assert fp.get("missing") is True
    assert config._auth_store_semantic_fingerprint(missing) == fp
