"""Iteration 8 — Dynamic Report Builder + Backup/Restore + Login regression.

Tests these features per the review request:
  • Login regression (timezone-naive datetime lockout bug fix in auth.py).
  • /api/reports/sources, /preview, saved CRUD, /run/{id}, /export CSV, /ai-summary.
  • /api/backup/create, /list, /verify/{id}, /download/{id}, /restore, DELETE,
    /schedule GET+PUT (with clamping).
  • Non-admin (cashier) 403 on /api/backup/list.

Each test is self-cleaning; created saved-reports + backups are removed in
fixture teardown.  Run with:
  pytest /app/backend/tests/test_reports_and_backup.py -v --tb=short
"""
import time
import pytest
import requests
from conftest import BASE_URL, ADMIN_EMAIL, ADMIN_PASSWORD


# ============ Auth / login regression =====================================
class TestLoginRegression:
    def test_login_success_returns_user_and_no_500(self):
        r = requests.post(f"{BASE_URL}/api/auth/login",
                          json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
                          timeout=30)
        assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text[:300]}"
        data = r.json()
        # Allow both legacy (user at top) and 2fa-challenge shapes
        if data.get("requires_2fa"):
            pytest.skip("Admin has 2FA enabled — login regression test skipped")
        assert "user" in data or "email" in data, data
        u = data.get("user") or data
        assert u.get("email") == ADMIN_EMAIL

    def test_login_wrong_password_401(self):
        r = requests.post(f"{BASE_URL}/api/auth/login",
                          json={"email": ADMIN_EMAIL, "password": "definitely-wrong-pw-xyz"},
                          timeout=30)
        # Could be 401 normally, or 429 if previous runs hit the lockout
        assert r.status_code in (401, 429), f"got {r.status_code}: {r.text[:200]}"
        if r.status_code == 401:
            body = r.json()
            msg = (body.get("detail") or body.get("message") or "").lower()
            assert "invalid" in msg or "incorrect" in msg or "wrong" in msg, body


# ============ /api/reports/sources ========================================
class TestReportSources:
    def test_sources_returns_six_with_fields(self, admin_session):
        r = admin_session.get(f"{BASE_URL}/api/reports/sources", timeout=30)
        assert r.status_code == 200, r.text
        sources = r.json()
        assert isinstance(sources, list)
        keys = {s["key"] for s in sources}
        assert keys == {"sales", "purchases", "payments", "expenses", "parties", "items"}, keys
        for s in sources:
            assert isinstance(s.get("fields"), list) and len(s["fields"]) > 0
            assert isinstance(s.get("aggregates"), list)


# ============ /api/reports/preview ========================================
class TestReportPreview:
    def test_preview_sales_basic(self, admin_session, default_company_id):
        body = {"source": "sales",
                "columns": ["invoice_no", "total", "status"],
                "limit": 5}
        r = admin_session.post(f"{BASE_URL}/api/reports/preview?company_id={default_company_id}",
                               json=body, timeout=30)
        assert r.status_code == 200, r.text
        data = r.json()
        assert "columns" in data and "rows" in data and "totals" in data
        assert "row_count" in data and "raw_count" in data
        assert isinstance(data["rows"], list)
        # Total column should be in totals (currency type)
        assert "total" in data["totals"]

    def test_preview_invalid_source_400(self, admin_session):
        r = admin_session.post(f"{BASE_URL}/api/reports/preview",
                               json={"source": "nope", "columns": ["a"]}, timeout=30)
        assert r.status_code == 400, r.text

    def test_preview_empty_columns_400(self, admin_session):
        r = admin_session.post(f"{BASE_URL}/api/reports/preview",
                               json={"source": "sales", "columns": []}, timeout=30)
        assert r.status_code == 400, r.text

    def test_preview_with_date_filter_sort_group(self, admin_session, default_company_id):
        body = {
            "source": "sales",
            "columns": ["party_name", "total"],
            "filters": {"date_from": "2020-01-01", "date_to": "2030-12-31"},
            "group_by": "party_name",
            "aggregates": {"total": "sum"},
            "sort_by": "total",
            "sort_dir": "desc",
            "limit": 50,
        }
        r = admin_session.post(f"{BASE_URL}/api/reports/preview?company_id={default_company_id}",
                               json=body, timeout=30)
        assert r.status_code == 200, r.text
        data = r.json()
        # Each row should now be a group by party
        if data["rows"]:
            keys = set(data["rows"][0].keys())
            assert "party_name" in keys


# ============ Saved CRUD + run/export =====================================
@pytest.fixture
def saved_report_id(admin_session, default_company_id):
    """Create a saved report, yield id, then delete in teardown."""
    body = {"name": f"TEST_RPT_{int(time.time())}",
            "source": "sales",
            "columns": ["invoice_no", "total"],
            "filters": {}, "limit": 10}
    r = admin_session.post(f"{BASE_URL}/api/reports/saved", json=body, timeout=30)
    assert r.status_code == 200, r.text
    rid = r.json()["id"]
    yield rid
    try:
        admin_session.delete(f"{BASE_URL}/api/reports/saved/{rid}", timeout=15)
    except Exception:
        pass


class TestSavedReports:
    def test_save_returns_id(self, saved_report_id):
        assert isinstance(saved_report_id, str) and len(saved_report_id) >= 10

    def test_list_includes_new(self, admin_session, saved_report_id):
        r = admin_session.get(f"{BASE_URL}/api/reports/saved", timeout=30)
        assert r.status_code == 200, r.text
        ids = {x["id"] for x in r.json()}
        assert saved_report_id in ids

    def test_run_returns_same_shape_as_preview(self, admin_session, saved_report_id, default_company_id):
        r = admin_session.post(f"{BASE_URL}/api/reports/run/{saved_report_id}?company_id={default_company_id}",
                               timeout=30)
        assert r.status_code == 200, r.text
        data = r.json()
        for k in ("columns", "rows", "totals", "row_count", "raw_count"):
            assert k in data

    def test_update_saved(self, admin_session, saved_report_id):
        new_body = {"name": f"TEST_RPT_UPD_{int(time.time())}",
                    "source": "sales",
                    "columns": ["invoice_no", "total", "status"],
                    "limit": 20}
        r = admin_session.put(f"{BASE_URL}/api/reports/saved/{saved_report_id}",
                              json=new_body, timeout=30)
        assert r.status_code == 200, r.text
        assert r.json().get("ok") is True

    def test_delete_saved(self, admin_session):
        # create then delete (independent of fixture so we can assert removal)
        body = {"name": f"TEST_RPT_DEL_{int(time.time())}",
                "source": "sales", "columns": ["total"], "limit": 5}
        c = admin_session.post(f"{BASE_URL}/api/reports/saved", json=body, timeout=30)
        assert c.status_code == 200, c.text
        rid = c.json()["id"]
        d = admin_session.delete(f"{BASE_URL}/api/reports/saved/{rid}", timeout=15)
        assert d.status_code == 200, d.text
        # Subsequent run should 404
        run = admin_session.post(f"{BASE_URL}/api/reports/run/{rid}", timeout=15)
        assert run.status_code == 404, run.status_code

    def test_export_csv(self, admin_session, default_company_id):
        body = {"definition": {"source": "sales",
                                "columns": ["invoice_no", "total"],
                                "limit": 5},
                "company_id": default_company_id}
        r = admin_session.post(f"{BASE_URL}/api/reports/export", json=body, timeout=30)
        assert r.status_code == 200, r.text
        assert r.headers.get("content-type", "").startswith("text/csv"), r.headers
        assert "attachment" in r.headers.get("content-disposition", "").lower()
        # First line is header labels
        assert r.text.splitlines()[0].lower().startswith("invoice no") or "Invoice" in r.text


# ============ AI Summary ==================================================
class TestAiSummary:
    def test_ai_summary_returns_text(self, admin_session, default_company_id):
        body = {"definition": {"source": "sales",
                                "columns": ["invoice_no", "total", "status"],
                                "limit": 10},
                "company_id": default_company_id}
        r = admin_session.post(f"{BASE_URL}/api/reports/ai-summary",
                               json=body, timeout=60)
        if r.status_code == 502:
            pytest.skip(f"AI provider unavailable: {r.text[:200]}")
        assert r.status_code == 200, r.text
        data = r.json()
        assert "summary" in data and isinstance(data["summary"], str)
        assert "row_count" in data and "totals" in data
        assert len(data["summary"]) > 10


# ============ Backup CRUD =================================================
@pytest.fixture
def created_backup_id(admin_session):
    r = admin_session.post(f"{BASE_URL}/api/backup/create",
                           json={"label": "TEST_pytest", "encrypt": True}, timeout=120)
    assert r.status_code == 200, r.text
    bk_id = r.json()["id"]
    yield bk_id
    # cleanup
    try:
        admin_session.delete(f"{BASE_URL}/api/backup/{bk_id}", timeout=15)
    except Exception:
        pass


class TestBackup:
    def test_create_backup(self, created_backup_id, admin_session):
        # Look it up in /list
        r = admin_session.get(f"{BASE_URL}/api/backup/list", timeout=30)
        assert r.status_code == 200, r.text
        rows = r.json()
        match = next((x for x in rows if x["id"] == created_backup_id), None)
        assert match is not None, f"{created_backup_id} not in list"
        assert match.get("encrypted") is True
        assert match.get("size_bytes", 0) > 1000
        assert match.get("total_rows", 0) >= 0

    def test_list_returns_array_newest_first(self, admin_session, created_backup_id):
        r = admin_session.get(f"{BASE_URL}/api/backup/list", timeout=30)
        assert r.status_code == 200, r.text
        rows = r.json()
        assert isinstance(rows, list) and len(rows) >= 1
        # newest first: the just-created one should be at top
        assert rows[0]["id"] == created_backup_id, [r["id"] for r in rows[:3]]

    def test_verify_sha256_matches(self, admin_session, created_backup_id):
        r = admin_session.post(f"{BASE_URL}/api/backup/verify/{created_backup_id}", timeout=60)
        assert r.status_code == 200, r.text
        data = r.json()
        assert data.get("ok") is True
        assert data.get("sha256_matches") is True, data

    def test_download_returns_binary(self, admin_session, created_backup_id):
        r = admin_session.get(f"{BASE_URL}/api/backup/download/{created_backup_id}",
                              timeout=60, stream=True)
        assert r.status_code == 200, r.text[:200]
        cd = r.headers.get("content-disposition", "")
        assert "attachment" in cd.lower(), cd
        body = r.content
        assert len(body) > 500

    def test_restore_merge_returns_counts(self, admin_session, created_backup_id):
        # Iteration-8 route collision fix verification: after renaming the legacy
        # utilities.py routes to /backup/legacy-{export,restore}, the JSON-body
        # restore in backup_engine.py is now reachable at /api/backup/restore.
        r = admin_session.post(f"{BASE_URL}/api/backup/restore",
                               json={"bk_id": created_backup_id, "mode": "merge"},
                               timeout=120)
        # Guard: any 422 with `file` field would indicate the collision regressed
        assert not (r.status_code == 422 and "file" in r.text.lower()), (
            f"ROUTE COLLISION REGRESSED: {r.text[:300]}"
        )
        assert r.status_code == 200, r.text
        data = r.json()
        assert data.get("ok") is True
        assert data.get("mode") == "merge"
        assert "total_inserted" in data and "total_updated" in data
        assert (data["total_inserted"] + data["total_updated"]) > 0

    def test_restore_replace_mode_wipes_then_restores(self, admin_session, created_backup_id):
        # Replace mode: each collection in the snapshot is wiped first, then re-inserted.
        # Using a fresh backup of current DB so this round-trips cleanly.
        r = admin_session.post(f"{BASE_URL}/api/backup/restore",
                               json={"bk_id": created_backup_id, "mode": "replace"},
                               timeout=180)
        assert r.status_code == 200, r.text
        data = r.json()
        assert data.get("ok") is True
        assert data.get("mode") == "replace"
        # After wipe, all rows should be inserts (no updates)
        assert data.get("total_updated", 0) == 0, data
        assert data.get("total_inserted", 0) > 0, data
        assert isinstance(data.get("summary"), dict) and len(data["summary"]) > 0

    def test_restore_missing_bk_id_returns_400(self, admin_session):
        r = admin_session.post(f"{BASE_URL}/api/backup/restore",
                               json={"mode": "merge"}, timeout=15)
        assert r.status_code == 400, r.text
        body = r.json()
        msg = (body.get("detail") or body.get("message") or "").lower()
        assert "bk_id" in msg, body

    def test_legacy_export_still_works(self, admin_session):
        # Renamed legacy endpoint: GET /api/backup/legacy-export must still
        # return a JSON snapshot attachment.
        r = admin_session.get(f"{BASE_URL}/api/backup/legacy-export", timeout=60)
        assert r.status_code == 200, r.text[:300]
        cd = r.headers.get("content-disposition", "")
        assert "attachment" in cd.lower(), cd
        # Validate it is a JSON bundle
        try:
            bundle = r.json()
        except Exception as e:
            pytest.fail(f"legacy-export did not return JSON: {e} :: {r.text[:200]}")
        assert "data" in bundle and isinstance(bundle["data"], dict), bundle.keys()
        assert "exported_at" in bundle

    def test_delete_backup(self, admin_session):
        # create separately so we can assert deletion (don't reuse fixture)
        c = admin_session.post(f"{BASE_URL}/api/backup/create",
                               json={"label": "TEST_del", "encrypt": True}, timeout=120)
        bk_id = c.json()["id"]
        d = admin_session.delete(f"{BASE_URL}/api/backup/{bk_id}", timeout=30)
        assert d.status_code == 200, d.text
        # Verify it's gone
        v = admin_session.post(f"{BASE_URL}/api/backup/verify/{bk_id}", timeout=15)
        assert v.status_code == 404, v.status_code


# ============ Schedule ====================================================
class TestBackupSchedule:
    def test_get_schedule_shape(self, admin_session):
        r = admin_session.get(f"{BASE_URL}/api/backup/schedule", timeout=15)
        assert r.status_code == 200, r.text
        data = r.json()
        for k in ("enabled", "interval_hours", "keep_last_n", "encrypt"):
            assert k in data, data

    def test_put_schedule_saves_and_clamps(self, admin_session):
        # Get current state to restore at end
        before = admin_session.get(f"{BASE_URL}/api/backup/schedule", timeout=15).json()
        try:
            r = admin_session.put(f"{BASE_URL}/api/backup/schedule",
                                  json={"enabled": True, "interval_hours": 12,
                                        "keep_last_n": 7, "encrypt": True}, timeout=15)
            assert r.status_code == 200, r.text
            verify = admin_session.get(f"{BASE_URL}/api/backup/schedule", timeout=15).json()
            assert verify["enabled"] is True
            assert verify["interval_hours"] == 12

            # Clamp test: 9999 → 720, 0 → 1
            r2 = admin_session.put(f"{BASE_URL}/api/backup/schedule",
                                   json={"enabled": True, "interval_hours": 9999,
                                         "keep_last_n": 7, "encrypt": True}, timeout=15)
            assert r2.status_code == 200, r2.text
            v2 = admin_session.get(f"{BASE_URL}/api/backup/schedule", timeout=15).json()
            assert v2["interval_hours"] == 720, v2

            r3 = admin_session.put(f"{BASE_URL}/api/backup/schedule",
                                   json={"enabled": True, "interval_hours": 0,
                                         "keep_last_n": 7, "encrypt": True}, timeout=15)
            assert r3.status_code == 200, r3.text
            v3 = admin_session.get(f"{BASE_URL}/api/backup/schedule", timeout=15).json()
            assert v3["interval_hours"] == 1, v3
        finally:
            # Restore prior state
            admin_session.put(f"{BASE_URL}/api/backup/schedule",
                              json={"enabled": bool(before.get("enabled", False)),
                                    "interval_hours": int(before.get("interval_hours", 24)),
                                    "keep_last_n": int(before.get("keep_last_n", 7)),
                                    "encrypt": bool(before.get("encrypt", True))}, timeout=15)


# ============ Non-admin permission ========================================
class TestBackupPermissions:
    def test_non_admin_403_on_list(self, admin_session):
        """Create a cashier user, login as them, expect 403 on /backup/list."""
        import secrets as _s
        email = f"TEST_cashier_{_s.token_hex(3)}@rmregal.com"
        pwd = "Test@12345"
        # Create via admin panel
        c = admin_session.post(f"{BASE_URL}/api/users",
                               json={"email": email, "password": pwd,
                                     "name": "Test Cashier", "role": "cashier"},
                               timeout=30)
        if c.status_code not in (200, 201):
            pytest.skip(f"Could not create cashier: {c.status_code} {c.text[:200]}")
        uid = c.json().get("id") or c.json().get("user", {}).get("id")
        try:
            # Login as cashier
            s = requests.Session()
            s.headers.update({"Content-Type": "application/json"})
            lr = s.post(f"{BASE_URL}/api/auth/login",
                        json={"email": email, "password": pwd}, timeout=30)
            if lr.status_code != 200:
                pytest.skip(f"Cashier login failed: {lr.status_code} {lr.text[:200]}")
            r = s.get(f"{BASE_URL}/api/backup/list", timeout=15)
            assert r.status_code == 403, f"expected 403, got {r.status_code}: {r.text[:200]}"
        finally:
            if uid:
                try:
                    admin_session.delete(f"{BASE_URL}/api/users/{uid}", timeout=15)
                except Exception:
                    pass
