"""Comprehensive tests for Accounts, Tariffs/Plans, Quota Buckets, Claude, and Grok in Hermes Hub."""
from __future__ import annotations

import json
import io
import os
import tempfile
import time
import urllib.error
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest

from antigravity_provider.router.account_identity import (
    AccountIdentity,
    QuotaBucket,
    QuotaSnapshot,
    SubscriptionPlan,
)
from antigravity_provider.router.quota_collector import AccountQuotaService
from antigravity_provider.router.adapters.claude_adapter import ClaudeAdapter
from antigravity_provider.router.adapters.grok_adapter import GrokAdapter
from antigravity_provider.router.claude_oauth import (
    ClaudeOAuthSession,
    start_claude_oauth,
    get_claude_oauth_session,
    cancel_claude_oauth_session,
)
from antigravity_provider.router.grok_oauth import (
    GrokOAuthSession,
    start_grok_oauth,
    get_grok_oauth_session,
    cancel_grok_oauth_session,
)
from antigravity_provider.router.health_tracker import (
    HealthTracker,
    HEALTHY,
    QUOTA_EXHAUSTED,
    extract_model_family,
)
from antigravity_provider.router.router_config import (
    RolePolicy,
    RouterConfig,
    RouterProfileConfig,
)
from antigravity_provider.router.router_engine import RouterEngine
from antigravity_provider.router.profile_manager import ProfileAuthManager


# ─────────────────────────────────────────────────────────────
# 1. SUBSCRIPTION PLAN TESTS
# ─────────────────────────────────────────────────────────────

def test_subscription_plan_known_codes():
    p1 = SubscriptionPlan.create("PRO")
    assert p1.code == "PRO"
    assert p1.display_name == "PRO"
    assert p1.is_known() is True

    p2 = SubscriptionPlan.create("plus")
    assert p2.code == "PLUS"
    assert p2.display_name == "PLUS"

    p3 = SubscriptionPlan.create("SuperGrok")
    assert p3.code == "SUPERGROK"
    assert p3.display_name == "SUPERGROK"

    p4 = SubscriptionPlan.create("grok_pro")
    assert p4.code == "GROK"
    assert p4.display_name == "GROK PRO"


def test_subscription_plan_unknown_fallback():
    p = SubscriptionPlan.create(None)
    assert p.code == "UNKNOWN"
    assert p.display_name == "Тариф: неизвестен"
    assert p.is_known() is False

    p_empty = SubscriptionPlan.create("")
    assert p_empty.code == "UNKNOWN"
    assert p_empty.display_name == "Тариф: неизвестен"


# ─────────────────────────────────────────────────────────────
# 2. ACCOUNT IDENTITY TESTS
# ─────────────────────────────────────────────────────────────

def test_account_identity_priority():
    # Priority: email -> display_name -> account_id -> profile_id
    ident1 = AccountIdentity(
        provider="antigravity",
        profile_id="ag-w1",
        email="developer@gmail.com",
        display_name="Dev Account",
        account_id="acc_12345",
    )
    assert ident1.primary_identifier() == "developer@gmail.com"

    ident2 = AccountIdentity(
        provider="openai-codex",
        profile_id="codex-worker-1",
        display_name="OpenAI Team",
        account_id="acc_67890",
    )
    assert ident2.primary_identifier() == "OpenAI Team"

    ident3 = AccountIdentity(
        provider="claude",
        profile_id="claude-worker-1",
        account_id="org_abcde",
    )
    assert ident3.primary_identifier() == "org_abcde"

    ident4 = AccountIdentity(
        provider="grok",
        profile_id="grok-worker-1",
    )
    assert ident4.primary_identifier() == "grok-worker-1"


def test_account_identity_masking():
    ident = AccountIdentity(provider="antigravity", profile_id="ag-1", email="john.doe@example.com")
    masked = ident.masked_identifier()
    assert "@example.com" in masked
    assert "john.doe" not in masked


# ─────────────────────────────────────────────────────────────
# 3. QUOTA BUCKET & SNAPSHOT TESTS
# ─────────────────────────────────────────────────────────────

def test_quota_bucket_percentage_reconciliation():
    b1 = QuotaBucket(id="b1", display_name="Session", used_percent=20.0)
    assert b1.remaining_percent == 80.0
    assert b1.status == "healthy"
    assert b1.formatted_remaining() == "Осталось 80%"

    b2 = QuotaBucket(id="b2", display_name="Weekly", remaining_percent=10.0)
    assert b2.used_percent == 90.0
    assert b2.status == "warning"

    b3 = QuotaBucket(id="b3", display_name="5h", remaining_percent=0.0)
    assert b3.status == "exhausted"
    assert b3.is_exhausted is True


def test_quota_bucket_absolute_counts():
    b = QuotaBucket(
        id="grok.tasks",
        display_name="Частые задачи",
        used_absolute=2,
        remaining_absolute=8,
        limit_absolute=10,
    )
    assert b.status == "healthy"
    assert "2/10" in b.formatted_remaining()


def test_quota_bucket_reset_formatting():
    now = datetime.now(timezone.utc)
    b = QuotaBucket(
        id="b1",
        display_name="5h",
        reset_at=now + timedelta(hours=3, minutes=25),
    )
    res_str = b.formatted_reset()
    assert res_str is not None
    assert "Сброс через 3ч" in res_str


def test_quota_snapshot_model_availability():
    b_claude = QuotaBucket(id="c1", display_name="Claude", model_family="claude", remaining_percent=0.0)
    b_gemini = QuotaBucket(id="g1", display_name="Gemini", model_family="gemini", remaining_percent=90.0)

    snap = QuotaSnapshot(
        account_id="ag-w1",
        provider="antigravity",
        buckets=[b_claude, b_gemini],
    )

    assert snap.is_model_available("claude-3-7-sonnet") is False
    assert snap.is_model_available("gemini-2.5-pro") is True


# ─────────────────────────────────────────────────────────────
# 4. ANTIGRAVITY SEPARATE CLAUDE & GEMINI QUOTA TESTS
# ─────────────────────────────────────────────────────────────

def test_antigravity_separate_claude_and_gemini_buckets():
    service = AccountQuotaService()
    response = MagicMock()
    response.__enter__.return_value.read.return_value = json.dumps(
        {
            "models": {
                "claude-sonnet-4-6": {
                    "quotaInfo": {"remainingFraction": 0.42, "resetTime": "2026-08-23T00:00:00Z"}
                },
                "gemini-3.7-flash": {
                    "quotaInfo": {"remainingFraction": 0.87, "resetTime": "2026-08-23T00:00:00Z"}
                },
            }
        }
    ).encode()
    with patch("antigravity_provider.cloudcode.load_or_onboard_project", return_value="project-1"), patch(
        "antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response
    ):
        snap = service._collect_antigravity_quota(
            "ag-w1", {"tokens": {"access_token": "access-token"}}
        )

    bucket_ids = [b.id for b in snap.buckets]
    assert "antigravity.claude.model_pool" in bucket_ids
    assert "antigravity.gemini.model_pool" in bucket_ids
    assert snap.source == "provider_api"

    # Claude bucket and Gemini bucket are independent
    b_c = snap.get_bucket_for_model("claude-3-7-sonnet")
    b_g = snap.get_bucket_for_model("gemini-2.5-pro")

    assert b_c is not None and b_c.model_family == "claude"
    assert b_g is not None and b_g.model_family == "gemini"
    assert b_c.remaining_percent == 42.0
    assert b_g.remaining_percent == 87.0


def test_antigravity_grouped_summary_includes_five_hour_and_weekly_buckets():
    service = AccountQuotaService()
    response = MagicMock()
    response.__enter__.return_value.read.return_value = json.dumps(
        {
            "groups": [
                {
                    "displayName": "Gemini Models",
                    "buckets": [
                        {
                            "bucketId": "gemini-weekly",
                            "window": "weekly",
                            "remainingFraction": 0.73,
                            "resetTime": "2026-08-27T00:00:00Z",
                        },
                        {
                            "bucketId": "gemini-5h",
                            "window": "5h",
                            "remainingFraction": 0.91,
                            "resetTime": "2026-08-23T05:00:00Z",
                        },
                    ],
                },
                {
                    "displayName": "Claude and GPT models",
                    "buckets": [
                        {
                            "bucketId": "3p-weekly",
                            "window": "weekly",
                            "remainingFraction": 0.44,
                            "resetTime": "2026-08-27T00:00:00Z",
                        },
                        {
                            "bucketId": "3p-5h",
                            "window": "5h",
                            "remainingFraction": 0.82,
                            "resetTime": "2026-08-23T05:00:00Z",
                        },
                    ],
                },
            ]
        }
    ).encode()

    with patch(
        "antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response
    ) as urlopen:
        snapshot = service._collect_antigravity_quota(
            "ag-w1", {"token": {"access_token": "access-token"}, "project_id": "project-1"}
        )

    assert urlopen.call_count == 1
    assert [bucket.id for bucket in snapshot.buckets] == [
        "antigravity.claude.5h",
        "antigravity.gemini.5h",
        "antigravity.claude.7d",
        "antigravity.gemini.7d",
    ]
    assert [bucket.remaining_percent for bucket in snapshot.buckets] == [82.0, 91.0, 44.0, 73.0]
    assert snapshot.get_bucket_for_model("gemini-3.1-pro").remaining_percent == 73.0
    assert snapshot.get_bucket_for_model("claude-sonnet-4-6").remaining_percent == 44.0


def test_antigravity_refreshes_expired_token_before_project_discovery():
    service = AccountQuotaService()
    response = MagicMock()
    response.__enter__.return_value.read.return_value = json.dumps(
        {
            "models": {
                "claude-sonnet-4-6": {
                    "quotaInfo": {"remainingFraction": 0.64, "resetTime": "2026-08-23T00:00:00Z"}
                },
                "gemini-3.7-flash": {
                    "quotaInfo": {"remainingFraction": 0.91, "resetTime": "2026-08-23T00:00:00Z"}
                },
            }
        }
    ).encode()
    auth_data = {
        "token": {
            "access_token": "expired-token",
            "refresh_token": "refresh-token",
            "expires_at": "2026-08-22T00:00:00Z",
        }
    }

    with patch(
        "antigravity_provider.oauth.refresh_access_token",
        return_value={"access_token": "fresh-token", "expires_at": "2026-08-23T00:00:00Z"},
    ) as refresh, patch(
        "antigravity_provider.cloudcode.load_or_onboard_project", return_value="project-1"
    ) as discover, patch(
        "antigravity_provider.router.quota_collector.ProfileAuthManager.save_profile_auth"
    ) as save, patch(
        "antigravity_provider.router.quota_collector.urllib.request.urlopen", return_value=response
    ) as urlopen:
        snapshot = service._collect_antigravity_quota("ag-w1", auth_data)

    refresh.assert_called_once_with("refresh-token")
    discover.assert_called_once_with("fresh-token")
    assert "Bearer fresh-token" == urlopen.call_args.args[0].headers["Authorization"]
    assert save.called
    assert auth_data["project_id"] == "project-1"
    assert snapshot.source == "provider_api"
    assert snapshot.get_bucket_for_model("claude-sonnet-4-6").remaining_percent == 64.0


def test_opencode_shows_published_limits_and_subscription_error():
    service = AccountQuotaService()
    models_response = MagicMock()
    models_response.__enter__.return_value.read.return_value = b'{"data": []}'
    entitlement_error = urllib.error.HTTPError(
        "https://opencode.ai/zen/go/v1/usage",
        403,
        "Forbidden",
        {},
        io.BytesIO(b'{"message":"OpenCode Go subscription required"}'),
    )

    with patch(
        "antigravity_provider.router.quota_collector.urllib.request.urlopen",
        side_effect=[models_response, entitlement_error],
    ):
        snapshot = service._collect_opencode_quota("opengo-1", {"api_key": "test-key"})

    # source описывает происхождение ЧИСЕЛ, а не факт ответа провайдера.
    # Здесь лимиты 12/30/60 взяты из опубликованной таблицы тарифа, а
    # остаток не измерен ни по одной корзине — заявлять provider_api
    # значит обещать измерение, которого не было. Информация о том, что
    # провайдер ответил и почему данных нет, сохраняется в
    # unavailable_reason и не теряется.
    assert snapshot.source == "baseline"
    assert snapshot.unavailable_reason == "Для этого ключа не активна подписка OpenCode Go"
    assert [bucket.limit_absolute for bucket in snapshot.buckets] == [12, 30, 60]
    assert [bucket.period for bucket in snapshot.buckets] == ["5h", "7d", "30d"]
    assert all(bucket.remaining_percent is None for bucket in snapshot.buckets)


def test_health_tracker_antigravity_claude_exhaustion_does_not_block_gemini(tmp_path):
    state_file = tmp_path / "router_state.json"
    tracker = HealthTracker(state_file=state_file)

    # Mark claude exhausted on ag-w1
    tracker.mark_quota_exhausted(profile_id="ag-w1", model_name="claude-3-7-sonnet", duration=1800)

    # Claude should be unhealthy
    assert tracker.is_healthy("ag-w1", "claude-3-7-sonnet") is False
    assert tracker.is_healthy("ag-w1", "claude-3-5-sonnet") is False

    # Gemini should remain healthy!
    assert tracker.is_healthy("ag-w1", "gemini-2.5-pro") is True


def test_live_measured_quota_clears_stale_exhaustion(tmp_path):
    tracker = HealthTracker(state_file=tmp_path / "router_state.json")
    tracker.mark_quota_exhausted("ag-w1", "gemini-3.1-pro", duration=3600, reason="old 429")
    assert tracker.is_healthy("ag-w1", "gemini-3.1-pro") is False

    changed = tracker.reconcile_measured_quota("ag-w1", {"gemini": 73.0, "claude": 44.0})

    assert changed is True
    assert tracker.is_healthy("ag-w1", "gemini-3.1-pro") is True
    assert tracker.is_healthy("ag-w1", "gemini-2.5-flash") is True


# ─────────────────────────────────────────────────────────────
# 5. ROUTER SAME-ACCOUNT MODEL FALLBACK TESTS
# ─────────────────────────────────────────────────────────────

def test_router_same_account_model_fallback(tmp_path):
    state_file = tmp_path / "router_state.json"
    tracker = HealthTracker(state_file=state_file)

    # Profile ag-w1 supports both claude and gemini
    p_ag = RouterProfileConfig(
        profile_id="ag-w1",
        provider="antigravity",
        preferred_models=["claude-3-7-sonnet", "gemini-2.5-pro"],
        capabilities=["code", "reasoning"],
    )

    config = RouterConfig(
        profiles={"ag-w1": p_ag},
        roles={
            "coder": RolePolicy(
                role_name="coder",
                preferred_chain=["ag-w1"],
                default_model="claude-3-7-sonnet",
            )
        },
    )

    # Mark claude quota exhausted on ag-w1
    tracker.mark_quota_exhausted("ag-w1", "claude-3-7-sonnet", duration=1800)

    engine = RouterEngine(config=config, health=tracker)

    # Mock adapter invocation
    mock_adapter = MagicMock()
    mock_adapter.invoke.return_value = {"choices": [{"message": {"content": "ok"}}]}

    with patch("antigravity_provider.router.router_engine.get_adapter", return_value=mock_adapter), \
         patch("antigravity_provider.router.settings_service.get_hub_settings", return_value={"prefer_same_account_model_fallback": True}):

        req = {"model": "claude-3-7-sonnet", "messages": [{"role": "user", "content": "hello"}]}
        resp = engine.route_request(req, role="coder")

        # Engine should fall back to gemini-2.5-pro on the same ag-w1 profile!
        assert "router_metadata" in resp
        assert resp["router_metadata"]["profile_id"] == "ag-w1"
        called_model = mock_adapter.invoke.call_args[0][1]["model"]
        assert called_model == "gemini-2.5-pro"


# ─────────────────────────────────────────────────────────────
# 6. CLAUDE & GROK ADAPTERS & ERROR CLASSIFICATION TESTS
# ─────────────────────────────────────────────────────────────

def test_claude_adapter_error_classification():
    adapter = ClaudeAdapter()
    
    # Quota / rate limit error
    err_quota = RuntimeError("Claude API Error (429): You have exceeded your current quota, please check your plan")
    c1 = adapter.classify_error(err_quota)
    assert c1.category == "quota-exhausted"

    # Auth error
    err_auth = RuntimeError("Claude API Error (401): Invalid API Key provided")
    c2 = adapter.classify_error(err_auth)
    assert c2.category == "auth-required"


def test_grok_adapter_error_classification():
    adapter = GrokAdapter()

    # Quota error
    err_quota = RuntimeError("Grok API Error (429): insufficient_quota for this billing period")
    c1 = adapter.classify_error(err_quota)
    assert c1.category == "quota-exhausted"

    # Auth error
    err_auth = RuntimeError("Grok API Error (403): Unauthorized access token")
    c2 = adapter.classify_error(err_auth)
    assert c2.category == "auth-required"


# ─────────────────────────────────────────────────────────────
# 7. CLAUDE & GROK OAUTH SESSIONS TESTS
# ─────────────────────────────────────────────────────────────

def test_claude_oauth_session_lifecycle(tmp_path):
    session_id, auth_url = start_claude_oauth("claude-test-slot")
    assert "claude.ai/oauth/authorize" in auth_url
    assert "code_challenge=" in auth_url

    session = get_claude_oauth_session(session_id)
    assert session is not None
    assert session.status == "pending"

    # Test direct token insertion fallback
    with patch("antigravity_provider.router.profile_manager.ProfileAuthManager.save_profile_auth") as mock_save:
        ok, msg = session.handle_auth_code('{"access_token": "sk-ant-test-token-1234567890"}')
        assert ok is True
        assert session.status == "completed"
        assert mock_save.called

    cancel_claude_oauth_session(session_id)


def test_grok_oauth_session_lifecycle(tmp_path):
    mock_dev_resp = {
        "device_code": "dev-12345",
        "user_code": "GRK-1234",
        "verification_uri": "https://auth.x.ai/device",
        "interval": 1,
        "expires_in": 300,
    }
    with patch("antigravity_provider.router.grok_oauth._post_form", return_value=mock_dev_resp):
        session_id, verify_url, code = start_grok_oauth("grok-test-slot", start_poll=False)
        assert "x.ai" in verify_url
        assert code == "GRK-1234"

        session = get_grok_oauth_session(session_id)
        assert session is not None

        # Test manual token fallback
        with patch("antigravity_provider.router.profile_manager.ProfileAuthManager.save_profile_auth") as mock_save:
            ok, msg = session.handle_manual_input('{"access_token": "xai-test-access-token-1234567890"}')
            assert ok is True
            assert session.status == "completed"
            assert mock_save.called

        cancel_grok_oauth_session(session_id)


# ─────────────────────────────────────────────────────────────
# 8. VERIFY PROFILE MANAGER TOKEN RESOLVERS
# ─────────────────────────────────────────────────────────────

def test_profile_manager_verify_claude_and_grok_tokens():
    ok, masked, models = ProfileAuthManager.verify_claude_token("sk-ant-api03-abcdef1234567890")
    assert ok is True
    assert "sk-ant" in masked
    assert "claude-3-7-sonnet" in models

    ok2, masked2, models2 = ProfileAuthManager.verify_grok_token("xai-abcdef1234567890123456")
    assert ok2 is True
    assert "xai" in masked2
    assert "grok-3" in models2
