"""Backend tests for Super Admin Control Panel (admin_panel.py + auth.py 2FA flow).

Covers: dashboard KPIs, 2FA setup/confirm/disable + full login challenge flow,
admin user controls (reset/temp/toggle/revoke/force-change/activity),
feature flags, maintenance mode (admin + public), announcements (CRUD + active),
audit log filtering, settings (read/update with clamping), audit on login
success/failure, admin-only authorization, deactivated user 403.

Test data is prefixed TEST_ and cleaned up afterwards.
"""
import os
import time
import uuid

import pyotp
import pytest
import requests

BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "https://offline-billing-pro-2.preview.emergentagent.com").rstrip("/")
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "regalmarketing2024@gmail.com")
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "Rvasa@#9955")


# ---- Session helpers ----------------------------------------------------------
def _new():
    s = requests.Session()
    s.headers.update({"Content-Type": "application/json"})
    return s


def _login(s, email, password):
    return s.post(f"{BASE_URL}/api/auth/login", json={"email": email, "password": password}, timeout=30)


@pytest.fixture(scope="module")
def admin():
    s = _new()
    r = _login(s, ADMIN_EMAIL, ADMIN_PASSWORD)
    if r.status_code != 200:
        pytest.skip(f"Admin login failed: {r.status_code} {r.text}")
    return s


@pytest.fixture(scope="module")
def admin_me(admin):
    r = admin.get(f"{BASE_URL}/api/auth/me", timeout=30)
    assert r.status_code == 200, r.text
    return r.json()


@pytest.fixture(scope="module")
def test_user(admin):
    """Create a TEST_ staff user we can mutate; clean up at end."""
    suffix = uuid.uuid4().hex[:6]
    email = f"TEST_admin_{suffix}@rmregal.com"
    payload = {"email": email, "password": "Pass1234", "name": f"TEST {suffix}", "role": "staff"}
    r = admin.post(f"{BASE_URL}/api/users", json=payload, timeout=30)
    assert r.status_code in (200, 201), f"create user: {r.status_code} {r.text}"
    data = r.json()
    uid = data.get("id") or data.get("_id") or data.get("user_id")
    assert uid, f"no user id in {data}"
    yield {"id": uid, "email": email, "password": "Pass1234"}
    # cleanup
    try:
        admin.delete(f"{BASE_URL}/api/users/{uid}", timeout=30)
    except Exception:
        pass


# ---- Dashboard ----------------------------------------------------------------
class TestDashboard:
    def test_dashboard_shape(self, admin):
        r = admin.get(f"{BASE_URL}/api/admin/dashboard", timeout=30)
        assert r.status_code == 200, r.text
        d = r.json()
        for k in ("users", "license", "devices", "logins", "business"):
            assert k in d, f"missing {k}"
        assert {"total", "active", "admins"}.issubset(d["users"].keys())
        assert {"recent_7d", "failed_7d", "trend_24h"}.issubset(d["logins"].keys())
        assert isinstance(d["logins"]["trend_24h"], list) and len(d["logins"]["trend_24h"]) == 24
        assert {"invoices", "parties", "items"}.issubset(d["business"].keys())

    def test_dashboard_admin_only(self, test_user):
        s = _new()
        r = _login(s, test_user["email"], test_user["password"])
        assert r.status_code == 200, r.text
        r2 = s.get(f"{BASE_URL}/api/admin/dashboard", timeout=30)
        assert r2.status_code == 403, f"expected 403 got {r2.status_code}: {r2.text}"


# ---- 2FA ----------------------------------------------------------------------
class TestTwoFA:
    def test_full_2fa_lifecycle_and_challenge_flow(self, admin, admin_me):
        # 1. status initially false (or already false from prior cleanup)
        r = admin.get(f"{BASE_URL}/api/admin/2fa/status", timeout=30)
        assert r.status_code == 200, r.text
        if r.json().get("enabled"):
            pytest.skip("Admin 2FA already enabled — skipping to avoid lockout")

        # 2. setup-start
        r = admin.post(f"{BASE_URL}/api/admin/2fa/setup-start", timeout=30)
        assert r.status_code == 200, r.text
        body = r.json()
        for k in ("secret", "uri", "qr_data_url"):
            assert k in body, f"missing {k}"
        assert body["qr_data_url"].startswith("data:image/png;base64,")
        secret = body["secret"]

        # 3. setup-confirm with wrong code → 400
        bad = admin.post(f"{BASE_URL}/api/admin/2fa/setup-confirm",
                         json={"code": "000000"}, timeout=30)
        assert bad.status_code == 400, bad.text

        # 4. setup-confirm with valid TOTP → enabled + 10 recovery codes
        code = pyotp.TOTP(secret).now()
        r = admin.post(f"{BASE_URL}/api/admin/2fa/setup-confirm",
                       json={"code": code}, timeout=30)
        assert r.status_code == 200, r.text
        rb = r.json()
        assert rb.get("ok") is True
        assert isinstance(rb.get("recovery_codes"), list) and len(rb["recovery_codes"]) == 10

        # 5. status now true
        r = admin.get(f"{BASE_URL}/api/admin/2fa/status", timeout=30)
        assert r.status_code == 200 and r.json().get("enabled") is True

        # 6. Full login challenge: a brand-new session must get requires_2fa=true
        try:
            s2 = _new()
            r = _login(s2, ADMIN_EMAIL, ADMIN_PASSWORD)
            assert r.status_code == 200, r.text
            j = r.json()
            assert j.get("requires_2fa") is True, f"expected challenge, got {j}"
            assert j.get("challenge_token"), "no challenge_token"
            challenge = j["challenge_token"]
            # cookies should NOT be set yet — /me must 401
            r_me = s2.get(f"{BASE_URL}/api/auth/me", timeout=30)
            assert r_me.status_code in (401, 403), f"unexpected /me after step1: {r_me.status_code}"

            # 7. Wrong code → 401
            r = s2.post(f"{BASE_URL}/api/auth/2fa/verify",
                        json={"challenge_token": challenge, "code": "000000"}, timeout=30)
            assert r.status_code == 401, r.text

            # 8. Right code → sets cookies and returns user
            verify_code = pyotp.TOTP(secret).now()
            r = s2.post(f"{BASE_URL}/api/auth/2fa/verify",
                        json={"challenge_token": challenge, "code": verify_code}, timeout=30)
            assert r.status_code == 200, r.text
            u = r.json()
            assert u.get("email") == ADMIN_EMAIL
            # Cookie present
            assert "access_token" in s2.cookies, f"no access_token cookie set; cookies={dict(s2.cookies)}"
            # /me works now
            r_me = s2.get(f"{BASE_URL}/api/auth/me", timeout=30)
            assert r_me.status_code == 200, r_me.text
        finally:
            # 9. Disable 2FA — first try without code (should 400 or 422)
            no_code = admin.post(f"{BASE_URL}/api/admin/2fa/disable", json={}, timeout=30)
            assert no_code.status_code in (400, 422), f"expected 400/422, got {no_code.status_code} {no_code.text}"

            # Disable with valid code
            disable_code = pyotp.TOTP(secret).now()
            r = admin.post(f"{BASE_URL}/api/admin/2fa/disable",
                           json={"code": disable_code}, timeout=30)
            assert r.status_code == 200, r.text
            # Confirm disabled
            r = admin.get(f"{BASE_URL}/api/admin/2fa/status", timeout=30)
            assert r.status_code == 200 and r.json().get("enabled") is False


# ---- User & Password Control --------------------------------------------------
class TestUserControl:
    def test_reset_password(self, admin, test_user):
        r = admin.post(f"{BASE_URL}/api/admin/users/{test_user['id']}/reset-password",
                       json={"new_password": "Pass1234", "force_change": True}, timeout=30)
        assert r.status_code == 200, r.text
        assert r.json().get("force_change") is True
        # User can log in with new password
        s = _new()
        r2 = _login(s, test_user["email"], "Pass1234")
        assert r2.status_code == 200, r2.text
        assert r2.json().get("force_password_update") is True

    def test_temp_password(self, admin, test_user):
        r = admin.post(f"{BASE_URL}/api/admin/users/{test_user['id']}/temp-password", timeout=30)
        assert r.status_code == 200, r.text
        j = r.json()
        pw = j.get("temp_password")
        assert pw and len(pw) == 12
        assert any(c.isalpha() for c in pw) and any(c.isdigit() for c in pw)
        assert j.get("force_change") is True
        # login works with the temp password
        s = _new()
        r2 = _login(s, test_user["email"], pw)
        assert r2.status_code == 200, r2.text
        # restore standard pw for downstream tests
        admin.post(f"{BASE_URL}/api/admin/users/{test_user['id']}/reset-password",
                   json={"new_password": "Pass1234", "force_change": False}, timeout=30)

    def test_toggle_active_twice(self, admin, test_user):
        r1 = admin.post(f"{BASE_URL}/api/admin/users/{test_user['id']}/toggle-active", timeout=30)
        assert r1.status_code == 200, r1.text
        assert r1.json().get("is_active") is False
        # Deactivated user can't login → 403
        s = _new()
        rl = _login(s, test_user["email"], "Pass1234")
        assert rl.status_code == 403, f"expected 403 got {rl.status_code} {rl.text}"

        r2 = admin.post(f"{BASE_URL}/api/admin/users/{test_user['id']}/toggle-active", timeout=30)
        assert r2.status_code == 200, r2.text
        assert r2.json().get("is_active") is True

    def test_toggle_active_self_409(self, admin, admin_me):
        r = admin.post(f"{BASE_URL}/api/admin/users/{admin_me['id']}/toggle-active", timeout=30)
        assert r.status_code == 409, f"expected 409 got {r.status_code} {r.text}"

    def test_revoke_sessions_kills_old_token(self, admin, test_user):
        # Login fresh as test_user — get a session with cookies
        s = _new()
        r = _login(s, test_user["email"], "Pass1234")
        assert r.status_code == 200, r.text
        # /me works
        assert s.get(f"{BASE_URL}/api/auth/me", timeout=30).status_code == 200

        # Sleep so revoke timestamp is strictly after token iat (iat second precision)
        time.sleep(2)
        r = admin.post(f"{BASE_URL}/api/admin/users/{test_user['id']}/revoke-sessions", timeout=30)
        assert r.status_code == 200, r.text
        assert r.json().get("tokens_invalid_before")

        # Old cookie now rejected
        r2 = s.get(f"{BASE_URL}/api/auth/me", timeout=30)
        assert r2.status_code == 401, f"expected 401 after revoke, got {r2.status_code}"

    def test_force_change(self, admin, test_user):
        r = admin.post(f"{BASE_URL}/api/admin/users/{test_user['id']}/force-change",
                       json={"enabled": True}, timeout=30)
        assert r.status_code == 200, r.text
        assert r.json().get("force_password_update") is True
        r2 = admin.post(f"{BASE_URL}/api/admin/users/{test_user['id']}/force-change",
                        json={"enabled": False}, timeout=30)
        assert r2.status_code == 200 and r2.json().get("force_password_update") is False

    def test_user_activity_returns_audit_rows(self, admin, test_user):
        r = admin.get(f"{BASE_URL}/api/admin/users/{test_user['id']}/activity", timeout=30)
        assert r.status_code == 200, r.text
        rows = r.json()
        assert isinstance(rows, list)
        # No _id ObjectId leakage
        for row in rows:
            assert "_id" not in row or isinstance(row.get("_id"), str)


# ---- Feature Flags ------------------------------------------------------------
class TestFeatures:
    def test_list_features(self, admin):
        r = admin.get(f"{BASE_URL}/api/admin/features", timeout=30)
        assert r.status_code == 200, r.text
        feats = r.json()
        assert isinstance(feats, dict)
        # spec says 15 keys, all default true
        assert len(feats) == 15, f"expected 15 features got {len(feats)}: {list(feats.keys())}"
        assert "ai_assistant" in feats

    def test_toggle_feature(self, admin):
        try:
            r = admin.put(f"{BASE_URL}/api/admin/features/ai_assistant",
                          json={"enabled": False}, timeout=30)
            assert r.status_code == 200, r.text
            feats = r.json()
            assert feats.get("ai_assistant") is False
            # GET reflects it
            g = admin.get(f"{BASE_URL}/api/admin/features", timeout=30).json()
            assert g.get("ai_assistant") is False
        finally:
            admin.put(f"{BASE_URL}/api/admin/features/ai_assistant",
                      json={"enabled": True}, timeout=30)

    def test_unknown_feature_404(self, admin):
        r = admin.put(f"{BASE_URL}/api/admin/features/no_such_thing",
                      json={"enabled": False}, timeout=30)
        assert r.status_code == 404


# ---- Maintenance --------------------------------------------------------------
class TestMaintenance:
    def test_maintenance_lifecycle(self, admin):
        # Initial state
        r = admin.get(f"{BASE_URL}/api/admin/maintenance", timeout=30)
        assert r.status_code == 200, r.text
        try:
            # Turn on
            r = admin.put(f"{BASE_URL}/api/admin/maintenance",
                          json={"enabled": True, "message": "Be right back"}, timeout=30)
            assert r.status_code == 200, r.text
            assert r.json().get("enabled") is True
            assert r.json().get("message") == "Be right back"

            # Public endpoint (no auth) reflects it
            s = requests.Session()
            r2 = s.get(f"{BASE_URL}/api/maintenance/public", timeout=30)
            assert r2.status_code == 200, r2.text
            assert r2.json().get("enabled") is True
        finally:
            admin.put(f"{BASE_URL}/api/admin/maintenance",
                      json={"enabled": False, "message": ""}, timeout=30)
            r = admin.get(f"{BASE_URL}/api/admin/maintenance", timeout=30)
            assert r.json().get("enabled") is False


# ---- Announcements ------------------------------------------------------------
class TestAnnouncements:
    def test_announcement_crud_and_active(self, admin):
        ann_id = None
        try:
            r = admin.post(f"{BASE_URL}/api/admin/announcements", json={
                "title": "TEST_Welcome", "body": "Hello from tests",
                "severity": "info", "audience": "all",
            }, timeout=30)
            assert r.status_code == 200, r.text
            ann_id = r.json().get("id")
            assert ann_id

            # List
            r = admin.get(f"{BASE_URL}/api/admin/announcements", timeout=30)
            assert r.status_code == 200, r.text
            ids = [a.get("id") for a in r.json()]
            assert ann_id in ids

            # Active list (logged-in admin)
            r = admin.get(f"{BASE_URL}/api/announcements/active", timeout=30)
            assert r.status_code == 200, r.text
            titles = [a.get("title") for a in r.json()]
            assert "TEST_Welcome" in titles
        finally:
            if ann_id:
                d = admin.delete(f"{BASE_URL}/api/admin/announcements/{ann_id}", timeout=30)
                assert d.status_code == 200, d.text


# ---- Audit Log ----------------------------------------------------------------
class TestAudit:
    def test_failed_login_writes_audit(self, admin):
        # Generate one failed login
        s = _new()
        r = _login(s, ADMIN_EMAIL, "wrong-password-xxxx")
        assert r.status_code == 401
        # Filter by action=login.failed
        r = admin.get(f"{BASE_URL}/api/admin/audit",
                      params={"action": "login.failed", "limit": 50}, timeout=30)
        assert r.status_code == 200, r.text
        body = r.json()
        assert "rows" in body
        assert body.get("count", 0) >= 1
        for row in body["rows"]:
            assert "failed" in row.get("action", "")

    def test_successful_login_writes_audit(self, admin):
        # Trigger fresh success
        s = _new()
        r = _login(s, ADMIN_EMAIL, ADMIN_PASSWORD)
        assert r.status_code == 200
        r = admin.get(f"{BASE_URL}/api/admin/audit",
                      params={"action": "login.success", "email": ADMIN_EMAIL, "limit": 10}, timeout=30)
        assert r.status_code == 200, r.text
        assert r.json().get("count", 0) >= 1

    def test_audit_filter_by_email(self, admin):
        r = admin.get(f"{BASE_URL}/api/admin/audit",
                      params={"email": ADMIN_EMAIL, "limit": 20}, timeout=30)
        assert r.status_code == 200, r.text
        for row in r.json().get("rows", []):
            assert ADMIN_EMAIL.lower() in (row.get("email", "") or "").lower()


# ---- Settings -----------------------------------------------------------------
class TestSettings:
    def test_read_settings(self, admin):
        r = admin.get(f"{BASE_URL}/api/admin/settings", timeout=30)
        assert r.status_code == 200, r.text
        s = r.json()
        assert s.get("min_password_length") in range(6, 65)
        assert s.get("session_timeout_minutes") == 720 or 5 <= s.get("session_timeout_minutes") <= 60 * 24 * 7

    def test_update_settings_clamps(self, admin):
        # Original
        orig = admin.get(f"{BASE_URL}/api/admin/settings", timeout=30).json()
        try:
            # Valid value
            r = admin.put(f"{BASE_URL}/api/admin/settings",
                          json={"min_password_length": 10}, timeout=30)
            assert r.status_code == 200 and r.json().get("min_password_length") == 10

            # Below floor (6) → clamps to 6
            r = admin.put(f"{BASE_URL}/api/admin/settings",
                          json={"min_password_length": 1}, timeout=30)
            assert r.status_code == 200 and r.json().get("min_password_length") == 6

            # Above ceiling (64) → clamps to 64
            r = admin.put(f"{BASE_URL}/api/admin/settings",
                          json={"min_password_length": 9999}, timeout=30)
            assert r.status_code == 200 and r.json().get("min_password_length") == 64
        finally:
            admin.put(f"{BASE_URL}/api/admin/settings",
                      json={"min_password_length": orig.get("min_password_length", 8)}, timeout=30)
