"""API integration tests (real PostgreSQL): auth, CSRF, recipients, subjects, templates."""
import base64
import io
import uuid

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 tpl_id_of(r) -> int:
    j = r.json()
    return j["id"] if "id" in j else j["template"]["id"]


def sub_id_of(r) -> int:
    j = r.json()
    return j["id"] if "id" in j else j["subject"]["id"]


# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
class TestAuth:
    def test_me_shape(self, api):
        r = api.get("/auth/me")
        assert r.status_code == 200
        u = r.json()
        assert u["email"] == api.email
        assert "password" not in u and "password_hash" not in u

    def test_me_requires_auth(self, api):
        # per-request cookies={} does NOT clear the httpx jar; use a fresh client
        from starlette.testclient import TestClient
        anon = TestClient(api.app)
        r = anon.get("/auth/me")
        assert r.status_code == 401

    def test_login_wrong_password(self, api):
        r = api.post("/auth/login", json={"email": api.email, "password": "Wrong#Pass123"},
                     headers=H(api))
        assert r.status_code == 401

    def test_login_unknown_user(self, api):
        r = api.post("/auth/login", json={"email": "nobody@x.com", "password": "Whatever#123"},
                     headers=H(api))
        assert r.status_code == 401

    def test_short_password_rejected(self, api):
        r = api.post("/auth/register",
                     json={"email": f"s{uuid.uuid4().hex[:8]}@x.com", "password": "short"},
                     headers=H(api))
        assert r.status_code in (400, 422)

    def test_duplicate_email_rejected(self, api):
        r = api.post("/auth/register", json={"email": api.email, "password": "Another#12345"},
                     headers=H(api))
        assert r.status_code in (400, 409, 422)

    def test_csrf_required_on_post(self, api):
        r = api.post("/auth/logout", headers={"X-CSRF-Token": ""})
        assert r.status_code in (401, 403)

    def test_csrf_wrong_token_rejected(self, api):
        r = api.post("/auth/logout", headers={"X-CSRF-Token": "forged-token"})
        assert r.status_code in (401, 403)

    def test_session_cookie_present_and_httponly(self, api):
        names = [c.name for c in api.cookies.jar]
        assert "mailer_session" in names
        r = api.post("/auth/login", json={"email": api.email, "password": api.password},
                     headers=H(api))
        setc = r.headers.get("set-cookie", "") or str(r.headers.get_list("set-cookie"))
        if "mailer_session" in setc:
            assert "httponly" in setc.lower()

    def test_google_start_without_config_reports_honestly(self, api):
        r = api.get("/auth/google/start")
        # without a configured client the app must not fabricate a Google URL
        assert r.status_code in (200, 400, 503)
        if r.status_code == 200:
            assert "accounts.google.com" in r.text
        else:
            assert "configuration" in r.text.lower() or "client" in r.text.lower()

    def test_google_callback_bad_state_rejected(self, api):
        r = api.get("/auth/google/callback?code=x&state=forged")
        assert r.status_code in (400, 403, 404, 422)

    def test_password_never_stored_plaintext(self, api, db):
        from sqlalchemy import text as sql_text

        row = db.execute(sql_text(
            "SELECT password_hash FROM users ORDER BY id DESC LIMIT 1")).fetchone()
        if row and row[0]:
            assert api.password not in row[0]
            assert len(row[0]) >= 30


# ---------------------------------------------------------------------------
# Recipients
# ---------------------------------------------------------------------------
class TestRecipientsApi:
    def _mk_list(self, api, name="List A"):
        r = api.post("/recipients/lists", headers=H(api), json={"name": name})
        assert r.status_code in (200, 201), r.text
        return r.json()["id"]

    def _import(self, api, lid, csv_data, mode="commit"):
        files = {"file": ("r.csv", io.BytesIO((csv_data).encode()), "text/csv")}
        if mode == "commit":
            return api.post(f"/recipients/import", headers=H(api),
                            data={"list_id": str(lid)}, files=files)
        return api.post("/recipients/import-preview", headers=H(api), files=files)

    def test_create_list(self, api):
        lid = self._mk_list(api)
        assert lid > 0
        lists = api.get("/recipients/lists").json()["lists"]
        assert any(l["id"] == lid for l in lists)

    def test_import_preview_reports_validation(self, api):
        lid = self._mk_list(api)
        csv_data = ("Email,First Name,Last Name\n"
                    "a@x.com,Alice,Smith\n"
                    "bad-syntax,Nope,Nope\n"
                    "a@x.com,Dupe,Row\n")
        r = self._import(api, lid, csv_data, mode="preview")
        assert r.status_code == 200, r.text
        prev = r.json()
        s = prev["summary"]
        assert s["valid_recipients"] == 1
        assert s["invalid_recipients"] == 1
        assert s["duplicate_recipients"] == 1
        assert prev["headers"][0].lower() == "email"

    def test_import_commit_stores_only_valid(self, api):
        lid = self._mk_list(api)
        csv_data = ("Email,First Name\n"
                    "a@x.com,Alice\n"
                    "b@y.com,Bob\n"
                    "bad,Nope\n"
                    "a@x.com,Dupe\n")
        r = self._import(api, lid, csv_data)
        assert r.status_code == 200, r.text
        j = r.json()
        # response contains the report; only 2 rows persisted
        d = api.get(f"/recipients/list?list_id={lid}").json()
        assert d["total"] == 2
        emails = [x["email"] for x in d["recipients"]]
        assert "a@x.com" in emails and "b@y.com" in emails and "bad" not in emails

    def test_import_preview_does_not_store(self, api):
        lid = self._mk_list(api)
        self._import(api, lid, "Email\na@x.com\n", mode="preview")
        assert api.get(f"/recipients/list?list_id={lid}").json()["total"] == 0

    def test_txt_import_one_per_line(self, api):
        lid = self._mk_list(api)
        files = {"file": ("r.txt", io.BytesIO(("a@x.com, Al, Riv\nb@y.com, Bo, Jon\n").encode()), "text/plain")}
        r = api.post("/recipients/import", headers=H(api), data={"list_id": str(lid)}, files=files)
        assert r.status_code == 200, r.text
        d = api.get(f"/recipients/list?list_id={lid}").json()
        assert d["total"] == 2
        first = d["recipients"][0]
        assert first["first_name"] == "Al" or first["email"] == "a@x.com"

    def test_json_import_rejected(self, api):
        lid = self._mk_list(api)
        files = {"file": ("r.json", io.BytesIO(('[{"email":"a@x.com"}]').encode()), "application/json")}
        r = api.post("/recipients/import", headers=H(api), data={"list_id": str(lid)}, files=files)
        assert r.status_code == 400, "JSON recipient files must be rejected"
        assert "json" in r.json()["detail"].lower()

    def test_unknown_extension_rejected(self, api):
        lid = self._mk_list(api)
        files = {"file": ("r.xlsx", io.BytesIO(("Email\na@x.com\n").encode()), "application/octet-stream")}
        r = api.post("/recipients/import", headers=H(api), data={"list_id": str(lid)}, files=files)
        assert r.status_code == 400

    def test_suppressed_not_imported(self, api):
        r = api.post("/suppression/add", headers=H(api),
                     json={"email": "sup@x.com", "reason": "unsubscribed"})
        assert r.status_code in (200, 201), r.text
        lid = self._mk_list(api)
        r2 = self._import(api, lid, "Email\nsup@x.com\nok@y.com\n")
        assert r2.status_code == 200
        d = api.get(f"/recipients/list?list_id={lid}").json()
        emails = [x["email"] for x in d["recipients"]]
        assert "sup@x.com" not in emails
        assert "ok@y.com" in emails

    def test_manual_add_and_invalid(self, api):
        lid = self._mk_list(api)
        r = api.post("/recipients/manual", headers=H(api), json={
            "list_id": lid,
            "recipient": {"email": "hand@x.com", "first_name": "Hand", "last_name": "Made"}})
        assert r.status_code in (200, 201), r.text
        d = api.get(f"/recipients/list?list_id={lid}").json()
        assert any(x["email"] == "hand@x.com" for x in d["recipients"])

        r2 = api.post("/recipients/manual", headers=H(api), json={
            "list_id": lid, "recipient": {"email": "not-an-email"}})
        assert r2.status_code in (400, 422)

    def test_search_filter(self, api):
        lid = self._mk_list(api)
        self._import(api, lid, "Email,First Name\nalpha@x.com,Al\nbeta@y.com,Be\n")
        d = api.get(f"/recipients/list?list_id={lid}&q=alpha").json()
        assert d["total"] == 1
        assert d["recipients"][0]["email"] == "alpha@x.com"

    def test_list_clear_and_delete(self, api):
        lid = self._mk_list(api)
        self._import(api, lid, "Email\na@x.com\nb@y.com\n")
        r = api.post(f"/recipients/lists/{lid}/clear", headers=H(api))
        assert r.status_code == 200, r.text
        assert api.get(f"/recipients/list?list_id={lid}").json()["total"] == 0
        r2 = api.delete(f"/recipients/lists/{lid}", headers=H(api))
        assert r2.status_code == 200, r.text

    def test_import_requires_auth(self, api):
        files = {"file": ("r.csv", io.BytesIO(("Email\na@x.com\n").encode()), "text/csv")}
        from starlette.testclient import TestClient
        anon = TestClient(api.app)
        r = anon.post("/recipients/import", data={"list_id": "1"}, files=files)
        assert r.status_code == 401

    def test_import_nonexistent_list_404(self, api):
        files = {"file": ("r.csv", io.BytesIO(("Email\na@x.com\n").encode()), "text/csv")}
        r = api.post("/recipients/import", headers=H(api), data={"list_id": "99999"}, files=files)
        assert r.status_code == 404


# ---------------------------------------------------------------------------
# Subjects
# ---------------------------------------------------------------------------
class TestSubjectsApi:
    def test_crud(self, api):
        r = api.post("/subjects", headers=H(api),
                     json={"subject": "Invoice %RandomInvoiceNumber% due"})
        assert r.status_code in (200, 201), r.text
        sid = sub_id_of(r)

        assert any(s["id"] == sid for s in api.get("/subjects/list").json()["subjects"])

        r3 = api.put(f"/subjects/{sid}", headers=H(api), json={"subject": "Updated subject"})
        assert r3.status_code == 200, r3.text

        # NOTE: there is intentionally no GET /subjects/{id} endpoint; updates are
        # verified through the list endpoint.
        updated = [s for s in api.get("/subjects/list").json()["subjects"] if s["id"] == sid]
        assert updated and updated[0]["subject"] == "Updated subject"

        r5 = api.delete(f"/subjects/{sid}", headers=H(api))
        assert r5.status_code == 200
        assert all(s["id"] != sid for s in api.get("/subjects/list").json()["subjects"])

    def test_empty_subject_rejected(self, api):
        r = api.post("/subjects", headers=H(api), json={"subject": ""})
        assert r.status_code in (400, 422)

    def test_search(self, api):
        api.post("/subjects", headers=H(api), json={"subject": "Invoice reminder alpha"})
        api.post("/subjects", headers=H(api), json={"subject": "Payment due beta"})
        d = api.get("/subjects/list?q=alpha").json()
        assert d["total"] == 1
        assert d["subjects"][0]["subject"] == "Invoice reminder alpha"

    def test_bulk_add(self, api):
        r = api.post("/subjects/bulk", headers=H(api),
                     json={"subjects": ["A one", "B two", "C three"]})
        assert r.status_code == 200, r.text
        d = api.get("/subjects/list").json()
        assert d["total"] >= 3

    def test_import_txt(self, api):
        files = {"file": ("s.txt", io.BytesIO(("Subject one\nSubject two\n").encode()), "text/plain")}
        r = api.post("/subjects/import", headers=H(api), files=files)
        assert r.status_code == 200, r.text
        d = api.get("/subjects/list").json()
        assert d["total"] >= 2

    def test_export(self, api):
        api.post("/subjects", headers=H(api), json={"subject": "Exportable subject XYZ"})
        r = api.get("/subjects/export")
        assert r.status_code == 200
        assert "Exportable subject XYZ" in r.text

    def test_clear_all(self, api):
        api.post("/subjects", headers=H(api), json={"subject": "One"})
        api.post("/subjects", headers=H(api), json={"subject": "Two"})
        r = api.post("/subjects/clear-all", headers=H(api))
        assert r.status_code == 200, r.text
        assert api.get("/subjects/list").json()["total"] == 0


# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------
class TestTemplatesApi:
    def test_create_list_preview_duplicate_delete(self, api):
        r = api.post("/templates", headers=H(api), json={
            "name": "Invoice Template",
            "subject_hint": "Invoice %RandomInvoiceNumber%",
            "body_html": "<p>Dear %FirstName%, invoice %RandomInvoiceNumber% "
                         "for %RandomTotal% is due %DueDate%.</p>"
                         "<p><a href=\"%UnsubscribeLink%\">Unsubscribe</a></p>",
            "body_text": "Dear %FirstName%",
        })
        assert r.status_code in (200, 201), r.text
        tid = tpl_id_of(r)

        t = next(x for x in api.get("/templates/list").json()["templates"] if x["id"] == tid)
        assert "FirstName" in t["variables"]
        assert "RandomInvoiceNumber" in t["variables"]

        r3 = api.post(f"/templates/{tid}/preview", headers=H(api), json={})
        assert r3.status_code == 200, r3.text
        pv = r3.json()
        # all variables substituted in rendered output (unknown ones flagged)
        html = pv.get("rendered_html", "")
        assert "%FirstName%" not in html
        assert "%RandomInvoiceNumber%" not in html
        assert "%UnsubscribeLink%" not in html

        r4 = api.post(f"/templates/{tid}/duplicate", headers=H(api))
        assert r4.status_code == 200, r4.text
        new_id = tpl_id_of(r4)
        assert new_id != tid

        r5 = api.delete(f"/templates/{tid}", headers=H(api))
        assert r5.status_code == 200
        assert all(x["id"] != tid for x in api.get("/templates/list").json()["templates"])

    def test_script_tags_rejected(self, api):
        r = api.post("/templates", headers=H(api), json={
            "name": "Bad", "body_html": "<p>x</p><script>alert(1)</script>"})
        assert r.status_code == 400
        assert "script" in str(r.json()).lower()

    def test_empty_html_rejected(self, api):
        r = api.post("/templates", headers=H(api), json={"name": "Empty", "body_html": ""})
        assert r.status_code == 400

    def test_unknown_variables_reported(self, api):
        r = api.post("/templates", headers=H(api), json={
            "name": "Unknown Vars", "body_html": "<p>%FirstName% and %TotallyBogus%</p>"})
        assert r.status_code in (200, 201)
        tid = tpl_id_of(r)
        t = next(x for x in api.get("/templates/list").json()["templates"] if x["id"] == tid)
        assert "TotallyBogus" in t["unknown_variables"]

    def test_get_single_template(self, api):
        r = api.post("/templates", headers=H(api), json={
            "name": "Solo", "body_html": "<p>hello %FirstName%</p>"})
        tid = tpl_id_of(r)
        r2 = api.get(f"/templates/{tid}")
        assert r2.status_code == 200
        assert r2.json()["name"] == "Solo"

    def test_upload_html_file(self, api):
        files = {"file": ("t.html", io.BytesIO(("<p>Uploaded %FirstName%</p>").encode()), "text/html")}
        r = api.post("/templates/upload", headers=H(api), files=files)
        assert r.status_code in (200, 201), r.text

    def test_upload_rejects_scripts(self, api):
        files = {"file": ("t.html", io.BytesIO(("<p>x</p><script>alert(1)</script>").encode()), "text/html")}
        r = api.post("/templates/upload", headers=H(api), files=files)
        assert r.status_code in (400, 422)

    def test_test_pdf_returns_real_pdf(self, api):
        r = api.post("/templates", headers=H(api), json={
            "name": "PDF T", "body_html": "<h1>Invoice</h1><p>Total %RandomTotal%</p>"})
        assert r.status_code in (200, 201), r.text
        tid = tpl_id_of(r)
        r2 = api.post(f"/templates/{tid}/test-pdf", headers=H(api), json={})
        assert r2.status_code == 200, r2.text
        body = r2.json()
        pdf = base64.b64decode(body["pdf_base64"])
        assert pdf[:4] == b"%PDF"
        assert body.get("size_bytes", len(pdf)) > 500

    def test_clear_all(self, api):
        api.post("/templates", headers=H(api), json={"name": "A", "body_html": "<p>a</p>"})
        api.post("/templates", headers=H(api), json={"name": "B", "body_html": "<p>b</p>"})
        r = api.post("/templates/clear-all", headers=H(api))
        assert r.status_code == 200, r.text
        assert api.get("/templates/list").json()["templates"] == []
