"""API integration tests (real PostgreSQL): attachments, phones, suppression,
settings, and logs (list/events/audit/dlq/export)."""
import hashlib
import io
import secrets

import pytest


def csrf(client) -> str:
    for c in client.cookies.jar:
        if c.name == "mailer_csrf":
            return c.value
    raise AssertionError("no csrf cookie")


def H(client, **extra):
    h = {"X-CSRF-Token": csrf(client)}
    h.update(extra)
    return h


def user_id_of(client) -> int:
    return client.get("/auth/me").json()["id"]


def _tpl(client, body="<p>Invoice %FirstName% %RandomTotal%</p>", name="Pdf Tpl"):
    r = client.post("/templates", headers=H(client),
                    json={"name": name, "body_html": body, "subject": "s"})
    assert r.status_code in (200, 201), r.text
    j = r.json()
    return j["id"] if "id" in j else j["template"]["id"]


# ---------------------------------------------------------------------------
# Attachments
# ---------------------------------------------------------------------------
class TestAttachmentsApi:
    def _upload(self, client, fname, content, mime, **params):
        files = {"file": (fname, io.BytesIO(content), mime)}
        return client.post("/attachments/upload", headers=H(client),
                           params=params, files=files)

    def test_txt_upload_stored_and_hashed(self, api):
        content = b"plain text attachment notes"
        r = self._upload(api, "notes.txt", content, "text/plain")
        assert r.status_code in (200, 201), r.text
        d = r.json()
        assert d["kind"] == "text"
        assert d["mime_type"] == "text/plain"
        assert d["size_bytes"] == len(content)
        assert d["file_sha256"] == hashlib.sha256(content).hexdigest()
        assert d["is_html_attachment"] is False
        assert d["name"] == "notes" or d["name"]  # name defaulted from filename

    def test_image_and_pdf_and_zip_kinds(self, api):
        png = b"\x89PNG\r\n\x1a\n" + b"0" * 64
        r = self._upload(api, "logo.png", png, "image/png")
        assert r.status_code in (200, 201), r.text
        assert r.json()["kind"] == "image"
        assert r.json()["mime_type"] == "image/png"

        pdf = b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF"
        r = self._upload(api, "doc.pdf", pdf, "application/pdf")
        assert r.status_code in (200, 201), r.text
        assert r.json()["kind"] == "pdf"

        z = b"PK\x03\x04" + b"0" * 32
        r = self._upload(api, "pack.zip", z, "application/zip")
        assert r.status_code in (200, 201), r.text
        assert r.json()["kind"] == "file"
        assert r.json()["mime_type"] == "application/zip"

    def test_html_upload_stored_inline(self, api):
        html = "<p>Invoice %RandomInvoiceNumber% for %FirstName%</p>"
        r = self._upload(api, "inv.html", html.encode(), "text/html",
                         name="Inline Invoice", description="per-recipient html")
        assert r.status_code in (200, 201), r.text
        d = r.json()
        assert d["is_html_attachment"] is True
        assert d["kind"] == "html"
        # GET single returns the editable html body
        r2 = api.get(f"/attachments/{d['id']}")
        assert r2.status_code == 200
        assert "%RandomInvoiceNumber%" in r2.json()["html_body"]
        assert r2.json()["name"] == "Inline Invoice"

    def test_upload_rejects_executable_signature(self, api):
        mz = b"MZ\x90\x00" + b"\x00" * 64
        r = self._upload(api, "evil.txt", mz, "text/plain")
        assert r.status_code == 400
        assert "malware" in r.text.lower()

    def test_upload_rejects_unsupported_extension(self, api):
        r = self._upload(api, "prog.exe", b"\x00" * 32, "application/octet-stream")
        assert r.status_code == 400
        assert "unsupported" in r.text.lower()

    def test_upload_rejects_empty_file(self, api):
        r = self._upload(api, "empty.txt", b"", "text/plain")
        assert r.status_code == 400

    def test_upload_rejects_html_with_script(self, api):
        r = self._upload(api, "bad.html", b"<p>x</p><script>alert(1)</script>", "text/html")
        assert r.status_code == 400

    def test_list_filters_kind_and_search(self, api):
        self._upload(api, "a.txt", b"text", "text/plain", name="Alpha Notes")
        self._upload(api, "b.png", b"\x89PNG\r\n\x1a\n" + b"0" * 16, "image/png",
                     name="Beta Logo")
        all_d = api.get("/attachments/list").json()
        assert all_d["total"] == 2
        txt = api.get("/attachments/list", params={"kind": "text"}).json()
        assert txt["total"] == 1 and txt["attachments"][0]["name"] == "Alpha Notes"
        found = api.get("/attachments/list", params={"search": "Beta"}).json()
        assert found["total"] == 1 and found["attachments"][0]["kind"] == "image"

    def test_download_roundtrip(self, api):
        content = b"round trip payload \x00\x01"
        r = self._upload(api, "rt.txt", content, "text/plain")
        att_id = r.json()["id"]
        r2 = api.get(f"/attachments/{att_id}/download")
        assert r2.status_code == 200
        assert r2.content == content

    def test_preview_text_and_image(self, api):
        r = self._upload(api, "prev.txt", b"previewable body", "text/plain")
        p = api.get(f"/attachments/{r.json()['id']}/preview").json()
        assert p["previewable"] is True and p["content"] == "previewable body"

        png = b"\x89PNG\r\n\x1a\n" + b"1" * 32
        r = self._upload(api, "prev.png", png, "image/png")
        p = api.get(f"/attachments/{r.json()['id']}/preview").json()
        assert p["previewable"] is True
        assert p["data_uri"].startswith("data:image/png;base64,")

    def test_convert_template_to_real_pdf(self, api):
        tid = _tpl(api)
        r = api.post("/attachments/convert-pdf", headers=H(api),
                     params={"template_id": tid})
        assert r.status_code in (200, 201), r.text
        d = r.json()
        assert d["kind"] == "pdf"
        assert d["size_bytes"] > 500
        # verify it is a real PDF through the preview data URI
        import base64
        p = api.get(f"/attachments/{d['id']}/preview").json()
        raw = base64.b64decode(p["data_uri"].split(",", 1)[1])
        assert raw[:5] == b"%PDF-"

    def test_convert_unknown_template_404(self, api):
        r = api.post("/attachments/convert-pdf", headers=H(api),
                     params={"template_id": 99999})
        assert r.status_code == 404

    def test_delete_and_404(self, api):
        r = self._upload(api, "del.txt", b"bye", "text/plain")
        att_id = r.json()["id"]
        r2 = api.post(f"/attachments/{att_id}/delete", headers=H(api))
        assert r2.status_code == 200 and r2.json()["deleted"] == att_id
        assert api.get(f"/attachments/{att_id}").status_code == 404

    def test_clear_all(self, api):
        for i in range(3):
            self._upload(api, f"c{i}.txt", b"x" * 8, "text/plain")
        r = api.post("/attachments/clear-all", headers=H(api))
        assert r.status_code == 200
        assert r.json()["deleted"] == 3
        assert api.get("/attachments/list").json()["total"] == 0

    def test_requires_auth(self, api):
        from starlette.testclient import TestClient
        anon = TestClient(api.app)
        files = {"file": ("a.txt", io.BytesIO(b"data"), "text/plain")}
        assert anon.post("/attachments/upload", files=files).status_code == 401
        assert anon.get("/attachments/list").status_code == 401

    def test_csrf_required(self, api):
        files = {"file": ("a.txt", io.BytesIO(b"data"), "text/plain")}
        r = api.post("/attachments/upload", files=files)  # no X-CSRF-Token
        assert r.status_code == 403


# ---------------------------------------------------------------------------
# Company phone numbers
# ---------------------------------------------------------------------------
class TestPhonesApi:
    def test_add_and_list(self, api):
        r = api.post("/phones/add", headers=H(api),
                     json={"phone": "+1 (555) 123-4567", "label": "Support"})
        assert r.status_code in (200, 201), r.text
        d = r.json()
        assert d["phone"] == "+1 (555) 123-4567"
        assert d["label"] == "Support"
        assert d["is_active"] is True

        lst = api.get("/phones/list").json()
        assert lst["total"] == 1
        assert lst["phones"][0]["phone"] == "+1 (555) 123-4567"

    def test_add_invalid_phone_rejected(self, api):
        r = api.post("/phones/add", headers=H(api), json={"phone": "not-a-phone"})
        assert r.status_code in (400, 422)

    def test_add_duplicate_409(self, api):
        api.post("/phones/add", headers=H(api), json={"phone": "5551234567"})
        r = api.post("/phones/add", headers=H(api), json={"phone": "5551234567"})
        assert r.status_code == 409

    def test_patch_and_delete(self, api):
        pid = api.post("/phones/add", headers=H(api),
                       json={"phone": "5559876543", "label": "Billing"}).json()["id"]
        r = api.post(f"/phones/{pid}/patch", headers=H(api),
                     json={"label": "Accounts", "is_active": False})
        assert r.status_code == 200
        assert r.json()["label"] == "Accounts" and r.json()["is_active"] is False

        # active_only filter now excludes it
        assert api.get("/phones/list", params={"active_only": "true"}).json()["total"] == 0

        r2 = api.post(f"/phones/{pid}/delete", headers=H(api))
        assert r2.status_code == 200 and r2.json()["deleted"] == pid
        r3 = api.post(f"/phones/{pid}/patch", headers=H(api), json={"label": "x"})
        assert r3.status_code == 404

    def test_search(self, api):
        api.post("/phones/add", headers=H(api), json={"phone": "1112223333", "label": "Sales"})
        api.post("/phones/add", headers=H(api), json={"phone": "4445556666", "label": "Help"})
        found = api.get("/phones/list", params={"search": "Sales"}).json()
        assert found["total"] == 1 and found["phones"][0]["phone"] == "1112223333"

    def test_import_csv_with_labels(self, api):
        csv_data = "phone,label\n5550001111,Desk A\n5550002222,Desk B\nbad!! ,??\n"
        files = {"file": ("p.csv", io.BytesIO(csv_data.encode()), "text/csv")}
        r = api.post("/phones/import", headers=H(api), files=files)
        assert r.status_code == 200, r.text
        d = r.json()
        assert d["added"] == 2
        assert d["total_valid"] == 2
        assert any("bad" in i for i in d["invalid"])

        # re-import: all duplicates
        r2 = api.post("/phones/import", headers=H(api), files={
            "file": ("p.csv", io.BytesIO(csv_data.encode()), "text/csv")})
        assert r2.json()["added"] == 0 and r2.json()["duplicates"] == 2

    def test_import_txt_one_per_line(self, api):
        files = {"file": ("p.txt", io.BytesIO(b"5551112222\n5551113333\n"), "text/plain")}
        r = api.post("/phones/import", headers=H(api), files=files)
        assert r.status_code == 200
        assert r.json()["added"] == 2
        assert api.get("/phones/list").json()["total"] == 2

    def test_import_rejects_json(self, api):
        files = {"file": ("p.json", io.BytesIO(b'[{"phone":"555"}]'), "application/json")}
        r = api.post("/phones/import", headers=H(api), files=files)
        assert r.status_code == 400
        assert "CSV or TXT" in r.text

    def test_import_requires_file(self, api):
        r = api.post("/phones/import", headers=H(api))
        assert r.status_code == 400

    def test_clear_all(self, api):
        for i in range(2):
            api.post("/phones/add", headers=H(api), json={"phone": f"555123000{i}"})
        r = api.post("/phones/clear-all", headers=H(api))
        assert r.status_code == 200 and r.json()["deleted"] == 2
        assert api.get("/phones/list").json()["total"] == 0

    def test_requires_auth(self, api):
        from starlette.testclient import TestClient
        anon = TestClient(api.app)
        assert anon.get("/phones/list").status_code == 401
        r = anon.post("/phones/add", json={"phone": "5551234567"})
        assert r.status_code == 401


# ---------------------------------------------------------------------------
# Suppression list
# ---------------------------------------------------------------------------
class TestSuppressionApi:
    def test_add_and_duplicate(self, api):
        r = api.post("/suppression/add", headers=H(api),
                     json={"email": "Block@Example.com", "reason": "bounced",
                           "source": "manual test"})
        assert r.status_code in (200, 201), r.text
        d = r.json()
        assert d["email"] == "block@example.com"  # normalized lowercase
        assert d["reason"] == "bounced"
        assert d["source"] == "manual test"

        r2 = api.post("/suppression/add", headers=H(api),
                      json={"email": "block@example.com"})
        assert r2.status_code == 409

    def test_reason_normalization(self, api):
        # "complained" -> "complaint"; unknown -> "manual"
        d1 = api.post("/suppression/add", headers=H(api),
                      json={"email": "a1@x.com", "reason": "complained"}).json()
        assert d1["reason"] == "complaint"
        d2 = api.post("/suppression/add", headers=H(api),
                      json={"email": "a2@x.com", "reason": "whatever"}).json()
        assert d2["reason"] == "manual"

    def test_bulk_and_check(self, api):
        r = api.post("/suppression/bulk", headers=H(api),
                     json={"emails": ["b1@x.com", "b2@x.com", "b1@x.com"], "reason": "manual"})
        assert r.status_code == 200
        assert r.json()["added"] == 2 and r.json()["duplicates"] == 1

        chk = api.post("/suppression/check", headers=H(api),
                       json={"emails": ["b1@x.com", "not@here.com"]})
        assert chk.status_code == 200
        s = chk.json()["suppressed"]
        assert s["b1@x.com"] is True and s["not@here.com"] is False

    def test_import_csv_with_reasons(self, api):
        csv_data = "email,reason\nc1@x.com,bounced\nc2@x.com,complained\nnope\n"
        files = {"file": ("s.csv", io.BytesIO(csv_data.encode()), "text/csv")}
        r = api.post("/suppression/import", headers=H(api), files=files)
        assert r.status_code == 200, r.text
        d = r.json()
        assert d["added"] == 2
        # complained normalizes to complaint
        rows = {e["email"]: e for e in api.get("/suppression/list").json()["entries"]}
        assert rows["c1@x.com"]["reason"] == "bounced"
        assert rows["c2@x.com"]["reason"] == "complaint"

    def test_import_txt_one_per_line(self, api):
        files = {"file": ("s.txt", io.BytesIO(b"t1@x.com\nt2@x.com\n"), "text/plain")}
        r = api.post("/suppression/import", headers=H(api),
                     files=files, params={"reason": "other"})
        assert r.status_code == 200 and r.json()["added"] == 2
        entries = api.get("/suppression/list").json()["entries"]
        assert all(e["reason"] == "other" for e in entries)

    def test_import_rejects_json(self, api):
        files = {"file": ("s.json", io.BytesIO(b'["x@y.com"]'), "application/json")}
        r = api.post("/suppression/import", headers=H(api), files=files)
        assert r.status_code == 400

    def test_list_search_and_reason_filter(self, api):
        api.post("/suppression/bulk", headers=H(api),
                 json={"emails": ["findme@x.com", "other@y.com"], "reason": "bounced"})
        assert api.get("/suppression/list", params={"search": "findme"}).json()["total"] == 1
        bounced = api.get("/suppression/list", params={"reason": "bounced"}).json()
        assert bounced["total"] == 2

    def test_delete_and_clear_all(self, api):
        sid = api.post("/suppression/add", headers=H(api),
                       json={"email": "gone@x.com"}).json()["id"]
        r = api.post(f"/suppression/{sid}/delete", headers=H(api))
        assert r.status_code == 200 and r.json()["deleted"] == sid
        assert api.get("/suppression/list").json()["total"] == 0

        api.post("/suppression/bulk", headers=H(api), json={"emails": ["z1@x.com", "z2@x.com"]})
        r2 = api.post("/suppression/clear-all", headers=H(api))
        assert r2.status_code == 200 and r2.json()["deleted"] == 2

    def test_requires_auth(self, api):
        from starlette.testclient import TestClient
        anon = TestClient(api.app)
        assert anon.get("/suppression/list").status_code == 401
        r = anon.post("/suppression/add", json={"email": "x@y.com"})
        assert r.status_code == 401


# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
class TestSettingsApi:
    def test_list_defaults(self, api):
        d = api.get("/settings/list").json()
        keys = {s["key"] for s in d["settings"]}
        for k in ("amount_min", "amount_max", "shipping_min", "shipping_max",
                  "invoice_format", "invoice_prefix", "default_daily_limit",
                  "attachment_kind", "unsubscribe_enabled", "company_name"):
            assert k in keys
        assert all(s["is_default"] for s in d["settings"])
        s0 = d["settings"][0]
        assert {"key", "label", "category", "type", "value", "default"} <= set(s0)

    def test_save_and_persist(self, api):
        r = api.post("/settings/save", headers=H(api),
                     json={"key": "amount_min", "value": 25.5})
        assert r.status_code == 200, r.text
        assert r.json()["value"] == 25.5
        row = [s for s in api.get("/settings/list").json()["settings"]
               if s["key"] == "amount_min"][0]
        assert row["value"] == 25.5 and row["is_default"] is False

    def test_save_rejects_out_of_range(self, api):
        r = api.post("/settings/save", headers=H(api),
                     json={"key": "amount_min", "value": -5})
        assert r.status_code == 400
        r2 = api.post("/settings/save", headers=H(api),
                      json={"key": "default_rate_per_minute", "value": 99999})
        assert r2.status_code == 400

    def test_save_rejects_unknown_key(self, api):
        r = api.post("/settings/save", headers=H(api),
                     json={"key": "no_such_setting", "value": 1})
        assert r.status_code == 400

    def test_bool_and_string_coercion(self, api):
        r = api.post("/settings/save", headers=H(api),
                     json={"key": "unsubscribe_enabled", "value": "false"})
        assert r.status_code == 200
        assert r.json()["value"] is False

        r2 = api.post("/settings/save", headers=H(api),
                      json={"key": "company_name", "value": "Acme Corp"})
        assert r2.status_code == 200
        assert r2.json()["value"] == "Acme Corp"

    def test_attachment_kind_whitelist(self, api):
        ok = api.post("/settings/save", headers=H(api),
                      json={"key": "attachment_kind", "value": "order"})
        assert ok.status_code == 200
        bad = api.post("/settings/save", headers=H(api),
                       json={"key": "attachment_kind", "value": "garbage"})
        assert bad.status_code == 400

    def test_invoice_format_requires_tokens(self, api):
        bad = api.post("/settings/save", headers=H(api),
                       json={"key": "invoice_format", "value": "INV-9999"})
        assert bad.status_code == 400
        ok = api.post("/settings/save", headers=H(api),
                      json={"key": "invoice_format", "value": "{prefix}{seq4}"})
        assert ok.status_code == 200

    def test_save_batch_partial_errors(self, api):
        r = api.post("/settings/save-batch", headers=H(api),
                     json={"values": {"amount_min": 10, "amount_max": 900,
                                      "bogus_key": 1, "shipping_min": "nope"}})
        assert r.status_code == 200
        d = r.json()
        assert d["applied"] == {"amount_min": 10, "amount_max": 900}
        assert "bogus_key" in d["errors"] and "shipping_min" in d["errors"]

    def test_reset_and_reset_all(self, api):
        api.post("/settings/save", headers=H(api), json={"key": "amount_min", "value": 77})
        api.post("/settings/save", headers=H(api), json={"key": "amount_max", "value": 888})
        r = api.post("/settings/reset", headers=H(api), json={"key": "amount_min"})
        assert r.status_code == 200
        row = [s for s in api.get("/settings/list").json()["settings"]
               if s["key"] == "amount_min"][0]
        assert row["is_default"] is True
        r2 = api.post("/settings/reset-all", headers=H(api))
        assert r2.status_code == 200 and r2.json()["reset"] == 1  # only amount_max left

    def test_requires_auth(self, api):
        from starlette.testclient import TestClient
        anon = TestClient(api.app)
        assert anon.get("/settings/list").status_code == 401
        r = anon.post("/settings/save", json={"key": "amount_min", "value": 1})
        assert r.status_code == 401

    def test_csrf_required(self, api):
        r = api.post("/settings/save", json={"key": "amount_min", "value": 5})
        assert r.status_code == 403


# ---------------------------------------------------------------------------
# Logs (send attempts, events, audit, DLQ, export)
# ---------------------------------------------------------------------------
class TestLogsApi:
    @staticmethod
    def _seed(db, user_id):
        """Seed one campaign with 2 recipients, jobs, attempts, and events."""
        from app.models import (Campaign, CampaignRecipient, EmailEvent, Recipient,
                                RecipientList, SendAttempt, SendJob)
        rl = RecipientList(user_id=user_id, name="logs list")
        db.add(rl)
        db.flush()
        r1 = Recipient(list_id=rl.id, email="logsa@x.com", unsub_token=secrets.token_hex(16))
        r2 = Recipient(list_id=rl.id, email="logsb@y.com", unsub_token=secrets.token_hex(16))
        db.add_all([r1, r2])
        db.flush()
        c = Campaign(user_id=user_id, name="logs campaign", status="completed")
        db.add(c)
        db.flush()
        cr1 = CampaignRecipient(campaign_id=c.id, recipient_id=r1.id,
                                email="logsa@x.com", status="sent", msg_id="m111")
        cr2 = CampaignRecipient(campaign_id=c.id, recipient_id=r2.id,
                                email="logsb@y.com", status="temp_failed")
        db.add_all([cr1, cr2])
        db.flush()
        j1 = SendJob(campaign_id=c.id, campaign_recipient_id=cr1.id, status="sent")
        j2 = SendJob(campaign_id=c.id, campaign_recipient_id=cr2.id, status="retrying")
        db.add_all([j1, j2])
        db.flush()
        a1 = SendAttempt(send_job_id=j1.id, campaign_id=c.id, recipient_email="logsa@x.com",
                         outcome="accepted", method="api", message_id="m111",
                         subject="Invoice A", template_name="T1",
                         provider_response="200 OK id=m111")
        a2 = SendAttempt(send_job_id=j2.id, campaign_id=c.id, recipient_email="logsb@y.com",
                         outcome="temp_failed", method="smtp", retry_no=1,
                         failure_reason="429 rate limited", error_class="temporary")
        a3 = SendAttempt(send_job_id=j2.id, campaign_id=c.id, recipient_email="logsb@y.com",
                         outcome="accepted", method="api", message_id="m222",
                         retry_no=2)
        db.add_all([a1, a2, a3])
        db.add_all([
            EmailEvent(campaign_id=c.id, campaign_recipient_id=cr1.id,
                       event_type="sent", detail="accepted", message_id="m111"),
            EmailEvent(campaign_id=c.id, campaign_recipient_id=cr2.id,
                       event_type="failed", detail="429 rate limited"),
        ])
        db.commit()
        return {"campaign_id": c.id, "cr1": cr1.id, "cr2": cr2.id}

    def test_empty_logs(self, api):
        d = api.get("/logs/list").json()
        assert d == {"logs": [], "total": 0, "page": 1, "page_size": 50}
        e = api.get("/logs/events").json()
        assert e["total"] == 0

    def test_list_shape_and_filters(self, api, db):
        ids = self._seed(db, user_id_of(api))
        d = api.get("/logs/list").json()
        assert d["total"] == 3
        log = d["logs"][0]
        for field in ("id", "campaign_id", "campaign_recipient_id", "account_id",
                      "attempt_number", "outcome", "error_class", "error_message",
                      "message_id", "method", "subject", "template_name",
                      "provider_response", "created_at"):
            assert field in log

        # outcome filter
        acc = api.get("/logs/list", params={"outcome": "accepted"}).json()
        assert acc["total"] == 2
        tf = api.get("/logs/list", params={"outcome": "temp_failed"}).json()
        assert tf["total"] == 1 and tf["logs"][0]["error_class"] == "temporary"

        # campaign filter
        cid = api.get("/logs/list").json()["logs"][0]["campaign_id"]
        assert api.get("/logs/list", params={"campaign_id": cid}).json()["total"] == 3
        assert api.get("/logs/list", params={"campaign_id": 99999}).json()["total"] == 0

        # search by email fragment and message id
        assert api.get("/logs/list", params={"search": "logsa"}).json()["total"] == 1
        assert api.get("/logs/list", params={"search": "m222"}).json()["total"] == 1

        # attempt numbers derive from retry_no
        attempts = sorted(api.get("/logs/list", params={"search": "logsb"}).json()["logs"],
                          key=lambda x: x["attempt_number"])
        assert [a["attempt_number"] for a in attempts] == [2, 3]

    def test_events_endpoint(self, api, db):
        self._seed(db, user_id_of(api))
        d = api.get("/logs/events").json()
        assert d["total"] == 2
        types = {e["event_type"] for e in d["events"]}
        assert types == {"sent", "failed"}
        filt = api.get("/logs/events", params={"event_type": "failed"}).json()
        assert filt["total"] == 1
        assert "429" in filt["events"][0]["detail"]

    def test_audit_shows_own_actions_only(self, api, db):
        # perform an audited action, then check the audit trail records it
        api.post("/suppression/add", headers=H(api), json={"email": "aud@x.com"})
        d = api.get("/logs/audit").json()
        assert d["total"] >= 1
        actions = [r["action"] for r in d["audit"]]
        assert "suppression.add" in actions
        row = [r for r in d["audit"] if r["action"] == "suppression.add"][0]
        assert row["detail"].get("reason") is not None
        # action filter
        filt = api.get("/logs/audit", params={"action": "suppression.add"}).json()
        assert filt["total"] == 1

    def test_dlq_endpoint_shape(self, api):
        r = api.get("/logs/dlq")
        assert r.status_code == 200
        d = r.json()
        assert "dlq" in d and "total" in d
        assert isinstance(d["dlq"], list)

    def test_export_csv(self, api, db):
        self._seed(db, user_id_of(api))
        r = api.get("/logs/export")
        assert r.status_code == 200
        assert "text/csv" in r.headers["content-type"]
        lines = r.text.strip().splitlines()
        assert lines[0].split(",") == ["attempt_id", "campaign_id", "email",
                                       "attempt_number", "outcome", "error_class",
                                       "error_message", "message_id", "method",
                                       "provider_response", "timestamp"]
        assert len(lines) == 4  # header + 3 attempts
        assert any("logsa@x.com" in ln for ln in lines[1:])

        r2 = api.get("/logs/export", params={"outcome": "temp_failed"})
        assert len(r2.text.strip().splitlines()) == 2

    def test_recipient_history_404(self, api):
        assert api.get("/logs/recipient/999999/history").status_code == 404

    def test_recipient_history_seeded(self, api, db):
        ids = self._seed(db, user_id_of(api))
        r = api.get(f"/logs/recipient/{ids['cr1']}/history")
        assert r.status_code == 200
        d = r.json()
        assert d["recipient"]["email"] == "logsa@x.com"
        assert any(a["message_id"] == "m111" for a in d["attempts"])

    def test_requires_auth(self, api):
        from starlette.testclient import TestClient
        anon = TestClient(api.app)
        assert anon.get("/logs/list").status_code == 401
        assert anon.get("/logs/audit").status_code == 401
