"""Free-trial dispatch injection: the pure-logic pieces that decide routing."""

import backend  # noqa: F401  (path sanity asserted below)

import pytest

from backend.apps.settings.models import AppSettings
from backend.apps.settings.credentials import proxy_auth
from backend.apps.agents.core.error_classify import (
    is_free_trial_exhausted,
    is_transient_capacity_error,
)
from backend.apps.agents.providers.registry import resolve_model_id_for_sdk
from backend.apps.subscription import free_trial as ft
from backend.apps.subscription.free_trial import has_own_model, arm_free_trial, clear_free_trial


def test_proxy_auth_for_each_mode():
    assert proxy_auth(AppSettings()) == (None, None)

    pro = AppSettings(
        connection_mode="openswarm-pro",
        openswarm_bearer_token="bear",
        openswarm_proxy_url="https://api.openswarm.com",
    )
    assert proxy_auth(pro) == ("bear", "https://api.openswarm.com")

    free = AppSettings(
        connection_mode="free-trial",
        free_trial_token="ftk",
        openswarm_proxy_url="https://api.openswarm.com",
    )
    # Free-trial carries the /free segment so the same SDK lands on the metered route.
    assert proxy_auth(free) == ("ftk", "https://api.openswarm.com/free")


def test_free_trial_resolves_to_a_bare_anthropic_id():
    s = AppSettings(connection_mode="free-trial", free_trial_token="ftk")
    mid = resolve_model_id_for_sdk("sonnet", s)
    # The bug this fixes: without the free-trial branch this returns a cc/-prefixed id that 401s when no Claude subscription is connected.
    assert "cc/" not in mid
    assert mid.startswith("claude-")


def test_exhaustion_is_classified_and_not_retried():
    assert is_free_trial_exhausted(Exception("error type free_trial_exhausted"))
    assert is_free_trial_exhausted(Exception("You've used your free OpenSwarm runs"))
    assert not is_free_trial_exhausted(Exception("overloaded, try again"))
    # Must NOT look transient, or the agent loop would retry a spent trial forever.
    assert not is_transient_capacity_error(Exception("free_trial_exhausted"))


def test_generic_cli_failure_uses_sdk_system_events_for_rate_limits():
    system_event_tail = (
        '{"subtype":"api_retry","data":{"error_status":429,'
        '"error":"rate_limit","max_retries":10}}'
    )
    assert is_transient_capacity_error(
        Exception("Command failed with exit code 1"),
        extra_text=system_event_tail,
    )


def testhas_own_model_never_shadows_a_real_provider():
    assert not has_own_model(AppSettings(connection_mode="free-trial", free_trial_token="x"))
    assert not has_own_model(AppSettings())
    assert has_own_model(AppSettings(anthropic_api_key="sk-ant-x"))
    assert has_own_model(
        AppSettings(connection_mode="openswarm-pro", openswarm_bearer_token="b")
    )


@pytest.mark.asyncio
async def test_arm_waits_for_9router_before_shadowing_a_background_started_sub(monkeypatch):
    """The regression: 9Router starts in the background, so at first-boot mint time
    a real Claude sub is invisible. arm() must bring 9Router up (so the sub becomes
    visible) BEFORE deciding, instead of arming the free trial over it."""
    saved: list = []
    monkeypatch.setattr(ft, "save_settings_async", _record(saved))
    monkeypatch.setattr(ft, "p_sync_routing", _noop)

    started = {"called": False}

    async def fake_ensure_running():
        started["called"] = True  # 9Router comes up here; the sub is now visible

    # The sub is only reachable AFTER ensure_running ran (mirrors the real race).
    async def sub_visible_after_start():
        return started["called"]

    import backend.apps.nine_router as nr
    monkeypatch.setattr(nr, "ensure_running", fake_ensure_running)
    monkeypatch.setattr(ft, "p_has_connected_subscription", sub_visible_after_start)

    s = AppSettings()  # no key, own_key mode: a subscription-only user
    out = await arm_free_trial(s)

    assert started["called"], "arm must start 9Router before trusting the sub check"
    assert out["armed"] is False and out["reason"] == "has_model"
    assert s.connection_mode == "own_key"
    assert s.default_model != "haiku"


@pytest.mark.asyncio
async def test_arm_tolerates_provider_load_lag(monkeypatch):
    """9Router's /api/providers can lag is_running on a cold start. arm must re-check
    a few times so a sub that loads a beat late is still caught, not shadowed."""
    monkeypatch.setattr(ft, "save_settings_async", _noop)
    monkeypatch.setattr(ft, "p_sync_routing", _noop)

    async def fake_ensure_running():
        return None

    calls = {"n": 0}
    async def lagging_sub():
        calls["n"] += 1
        return calls["n"] >= 3  # empty for the first two probes, then the sub appears

    import backend.apps.nine_router as nr
    monkeypatch.setattr(nr, "ensure_running", fake_ensure_running)
    monkeypatch.setattr(ft, "p_has_connected_subscription", lagging_sub)

    s = AppSettings()
    res = await ft.arm_free_trial(s)
    assert res["reason"] == "has_model", res
    assert s.default_model != "haiku"
    assert calls["n"] >= 3, "should have re-checked past the lagging-empty probes"


@pytest.mark.asyncio
async def test_arm_with_no_sub_is_bounded_and_falls_through_to_arm(monkeypatch):
    """The 'don't poll for something that doesn't exist' guarantee: a genuinely
    sub-less user must exhaust the re-checks quickly and PROCEED to arm, never hang."""
    async def fake_ensure_running():
        return None
    async def never_sub():
        return False

    import time
    import backend.apps.nine_router as nr
    monkeypatch.setattr(nr, "ensure_running", fake_ensure_running)
    monkeypatch.setattr(ft, "p_has_connected_subscription", never_sub)
    # Short-circuit before the cloud mint so the test stays offline + deterministic; reaching this branch proves arm did NOT falsely conclude has_model.
    monkeypatch.setattr(ft, "p_fingerprint", lambda _s: None)

    s = AppSettings()
    t = time.monotonic()
    res = await ft.arm_free_trial(s)
    elapsed = time.monotonic() - t
    assert res["reason"] == "no_fingerprint", res  # got past the sub guard to the arm path
    assert elapsed < 3.0, f"re-check budget not bounded: {elapsed:.2f}s"


@pytest.mark.asyncio
async def test_clear_keeps_haiku_as_trial_face_unless_a_real_model_is_connected(monkeypatch):
    monkeypatch.setattr(ft, "save_settings_async", _noop)
    monkeypatch.setattr(ft, "p_sync_routing", _noop)

    # No key + no sub: hand back the wheel but KEEP haiku as the (send-gated) trial face. Flipping to sonnet here would show a model the user can't use either; the out-of-runs UI does the gating.
    monkeypatch.setattr(ft, "p_has_connected_subscription", _false)
    s = AppSettings(connection_mode="free-trial", free_trial_token="ftk", default_model="haiku")
    await clear_free_trial(s)
    assert s.connection_mode == "own_key"
    assert s.default_model == "haiku"
    assert s.free_trial_token is None

    # A real API key connected: hand forced haiku back to sonnet so a payer is never pinned to Haiku.
    s2 = AppSettings(connection_mode="free-trial", free_trial_token="ftk",
                     default_model="haiku", anthropic_api_key="sk-ant-real")
    await clear_free_trial(s2)
    assert s2.default_model == "opus-5"

    # A connected 9Router subscription (invisible in settings) also frees haiku -> sonnet.
    monkeypatch.setattr(ft, "p_has_connected_subscription", _true)
    s3 = AppSettings(connection_mode="free-trial", free_trial_token="ftk", default_model="haiku")
    await clear_free_trial(s3)
    assert s3.default_model == "opus-5"

    # A user who deliberately picked haiku OUTSIDE free-trial mode is left alone.
    s4 = AppSettings(connection_mode="own_key", default_model="haiku")
    await clear_free_trial(s4)
    assert s4.default_model == "haiku"


async def _noop(*_a, **_k):
    return None


async def _false(*_a, **_k):
    return False


async def _true(*_a, **_k):
    return True


def _record(bucket):
    async def _inner(obj, *_a, **_k):
        bucket.append(obj)
    return _inner
