"""Mail is bounded and batched (R2 task T6): every send counts against a daily cap in
`email_sends`, and notifications go out as one digest per person, never a mail per row; the
digest lists what the panel's Needs-you tab lists, nothing else (R2b task T14).

What the caps prevent: a compromised session, a stuck loop or a mistyped cron line sending in
anyone's name without limit (6 auth mails per account, 25 invites per host, 40 notification
mails per account, per day). What the digest guarantees: a row already read in the app is
stamped and not mailed, a person who turned mail off is skipped, the notify cap leaves rows for
the next run rather than dropping them, `--check` writes nothing, and with no provider nothing
is stamped. In-process on the accounts suite's SQLite with the recording mailer.
"""

import argparse
from datetime import UTC, datetime, timedelta

import pytest
from sqlalchemy import select

from app import cli_notify
from app.models import Account, AccountLevel
from app.models.discussion import AccountPreference, EmailSend, Notification
from app.services import accounts, mail
from app.services import discussion as disc
from app.vendor.bw_accounts import AccountsError
from tests import _accounts as T
from tests.kit import _env


@pytest.fixture
def world(monkeypatch):
    recorder = T.fresh(monkeypatch)
    T.person("rian", display_name="Rian")
    with _env.TestSessionLocal() as db:
        db.add(AccountLevel(name="admin", permissions=list(accounts.SEED_LEVELS["admin"]["permissions"]), assignable=[]))
        db.commit()
    T.person("adam", level="admin", display_name="Adam")
    T.person("mark", level="admin", display_name="Mark")
    monkeypatch.setattr(disc.settings, "public_base_url", "https://dfp.test")
    # send_capped opens the app's own session for the ledger; point it at the test one, or the
    # test reads whatever database the checkout happens to be configured for.
    monkeypatch.setattr(mail.appdb, "SessionLocal", _env.TestSessionLocal)
    yield recorder


def post(c, subject_type, subject_id, body):
    r = c.post("/api/discussion/comments", json={"subject_type": subject_type, "subject_id": subject_id, "body": body})
    assert r.status_code == 201, r.text


class TestCaps:
    def test_the_ledger_counts_a_window_and_the_cap_refuses_before_the_provider(self, world):
        now = datetime.now(UTC)  # the capped send reads the real clock; a fixed date expires
        with _env.TestSessionLocal() as db:
            for i in range(6):
                mail.record(db, "auth:7", now=now - timedelta(hours=i))
            mail.record(db, "auth:7", now=now - timedelta(days=2))  # outside the window
            db.commit()
            assert mail.sent_in_window(db, "auth:7", now=now) == 6
            assert not mail.allowed(db, "auth:7", now=now)
            assert mail.allowed(db, "auth:8", now=now) and mail.allowed(db, "invite:host", now=now)
        before = len(world.sent)
        with pytest.raises(mail.MailCapReached):
            mail.send_capped("auth:7", "a@example.com", "s", "t")
        assert len(world.sent) == before  # the provider was never called
        mail.send_capped("notify:7", "a@example.com", "s", "t")
        assert world.sent[-1][0] == "a@example.com"
        with _env.TestSessionLocal() as db:
            assert db.scalar(select(EmailSend).where(EmailSend.bucket == "notify:7")) is not None

    def test_a_reset_link_counts_against_the_accounts_auth_cap(self, world):
        directory = accounts.directory_instance()
        with _env.TestSessionLocal() as db:
            adam_id = db.scalar(select(Account.id).where(Account.username == "adam"))
            for _ in range(6):
                mail.record(db, mail.bucket("auth", adam_id))
            db.commit()
        with pytest.raises(AccountsError) as exc:
            directory.send_reset("adam")
        assert exc.value.code == "MAIL_CAP"
        assert directory.send_reset("mark")["emailed_to"] == "mark@example.com"
        assert world.sent[-1][0] == "mark@example.com"


def args(check: bool = False) -> argparse.Namespace:
    return argparse.Namespace(check=check)


class TestDigest:
    def test_one_mail_per_person_read_rows_stamped_not_mailed_and_preferences_honoured(self, world, capsys):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "structure", "urls", "a question for @mark and @rian")
        post(c, "structure", "map", "another for @mark")
        T.as_user(c, "mark")
        first = c.get("/api/notifications").json()["notifications"]
        assert len(first) == 2
        c.post("/api/notifications/read", json={"ids": [first[0]["id"]]})  # mark read one in the app
        T.as_user(c, "rian")
        assert c.post("/api/notifications/preferences", json={"mail_notifications": False}).json() == {"mail_notifications": False}
        assert c.get("/api/notifications/preferences").json() == {"mail_notifications": False}

        # --check: the plan, nothing written.
        before = len(world.sent)
        assert cli_notify.cmd_digest(args(check=True)) == 0
        out = capsys.readouterr().out
        assert "would send to mark: 1 thing waiting for you on Duty Free Professor" in out
        assert "skip rian: mail off" in out and "[check, nothing written]" in out
        assert len(world.sent) == before
        with _env.TestSessionLocal() as db:
            assert all(n.delivered_at is None for n in db.scalars(select(Notification)))

        # The real run: mark gets one mail with the unread row's line and its deep link; both of
        # his rows are stamped; rian's are left for later.
        assert cli_notify.cmd_digest(args()) == 0
        assert len(world.sent) == before + 1
        to, subject, text = world.sent[-1]
        assert to == "mark@example.com" and subject.startswith("1 thing waiting")
        # The inbox is newest first, so the row he read was the map one: the urls one is mailed.
        assert "Adam mentioned you on structure › urls" in text and "structure › map" not in text
        assert "https://dfp.test/discuss?tab=structure#t-structure-urls#c-" in text and "/account" in text
        with _env.TestSessionLocal() as db:
            marks = [n for n in db.scalars(select(Notification)) if n.recipient_id == 3]
            rians = [n for n in db.scalars(select(Notification)) if n.recipient_id == 1]
            assert all(n.delivered_at is not None for n in marks) and len(marks) == 2
            assert all(n.delivered_at is None for n in rians)
            assert db.scalar(select(EmailSend).where(EmailSend.bucket == "notify:3")) is not None
        # A second run has nothing for mark and still skips rian.
        assert cli_notify.cmd_digest(args()) == 0
        assert len(world.sent) == before + 1

    def test_the_notify_cap_leaves_rows_for_the_next_run(self, world, capsys):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "quote", "guides", "over to @mark")
        with _env.TestSessionLocal() as db:
            for _ in range(40):
                mail.record(db, "notify:3")
            db.commit()
        assert cli_notify.cmd_digest(args()) == 0
        assert "cap  mark" in capsys.readouterr().out
        assert all(to != "mark@example.com" for to, _, _ in world.sent)
        with _env.TestSessionLocal() as db:
            assert all(n.delivered_at is None for n in db.scalars(select(Notification)) if n.recipient_id == 3)

    def test_the_digest_mails_what_needs_you_and_stamps_the_rest(self, world):
        """A reply, a resolution or a decision is the bell's news, never a mail (T14)."""
        c = T.client()
        T.as_user(c, "adam")
        post(c, "structure", "urls", "a thought, no name in it")  # rian, a curator: a reply row
        T.as_user(c, "mark")
        post(c, "structure", "urls", "and mine")  # adam: a reply; rian: a reply
        before = len(world.sent)
        assert cli_notify.cmd_digest(args()) == 0
        assert len(world.sent) == before  # nobody was mentioned: no mail
        with _env.TestSessionLocal() as db:
            rows = list(db.scalars(select(Notification)))
            assert rows and all(n.kind == "reply" and n.delivered_at is not None for n in rows)
        T.as_user(c, "adam")
        post(c, "structure", "urls", "@rian your call")
        assert cli_notify.cmd_digest(args()) == 0
        assert len(world.sent) == before + 1 and world.sent[-1][0] == "rian@example.com"
        assert "Adam mentioned you" in world.sent[-1][2] and "replied" not in world.sent[-1][2]

    def test_no_provider_means_nothing_stamped(self, world, monkeypatch, capsys):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "quote", "guides", "over to @mark")
        monkeypatch.setattr(mail, "_override", None)
        monkeypatch.setattr(mail.settings, "mail_provider", "none")
        assert cli_notify.cmd_digest(args()) == 0
        assert "no mail provider" in capsys.readouterr().out
        with _env.TestSessionLocal() as db:
            assert all(n.delivered_at is None for n in db.scalars(select(Notification)))
            assert db.get(AccountPreference, 3) is None  # nothing was written on anyone's behalf
