"""Email reader card tests — the thread engine (voice/mail_view.py), the Gmail
thread reader (voice/gmail_control.py), the IMAP one (voice/email_control.py)
and the HUD→engine contract (control/email_action.json, applied by
ControlWatcher._email_action). The JSON payloads here are EXACTLY what the Mac
HUD's EmailPanel writes, so these tests pin the contract between the Swift card
and the Python engine. No network, no HUD, no audio (fakes throughout).

Run:  .venv/bin/python -m pytest test_mail_card.py -q
"""
from __future__ import annotations

import asyncio
import base64
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage

import voice.control as control
import voice.mail_view as mail_view
from voice import email_control, gmail_control


# ---------- fakes ----------

class FakeInbox:
    def __init__(self):
        self.notes = []

    def put_nowait(self, note):
        self.notes.append(note)


class FakeApp:
    def __init__(self):
        self.inbox = FakeInbox()


def make_watcher():
    """A ControlWatcher with no __init__ side effects — just enough to drive
    _email_action."""
    w = control.ControlWatcher.__new__(control.ControlWatcher)
    w.app = FakeApp()
    return w


def _b64u(s: str | bytes) -> str:
    b = s.encode() if isinstance(s, str) else s
    return base64.urlsafe_b64encode(b).decode().rstrip("=")


PNG = b"\x89PNG\r\n\x1a\n-fake-"
PDF = b"%PDF-fake-quote"

# A realistic 2-message Gmail thread: Elie writes (HTML + inline logo + a PDF),
# Ahmed replies (plain text, SENT label).
THREAD = {
    "id": "T1",
    "messages": [
        {
            "id": "M2", "threadId": "T1", "internalDate": "1752400000000",
            "labelIds": ["SENT"],
            "payload": {
                "mimeType": "text/plain",
                "headers": [
                    {"name": "From",
                     "value": "Ahmed <ahmed.alrajeh@alrugaibfurniture.com>"},
                    {"name": "To", "value": "Elie <elie@example.com>"},
                    {"name": "Subject", "value": "Re: Statue quote"},
                    {"name": "Date", "value": "Sun, 13 Jul 2026 12:00:00 +0300"},
                ],
                "body": {"size": 9, "data": _b64u("Looks good")},
            },
        },
        {
            "id": "M1", "threadId": "T1", "internalDate": "1752300000000",
            "labelIds": ["INBOX", "UNREAD"],
            "payload": {
                "mimeType": "multipart/mixed",
                "headers": [
                    {"name": "From", "value": "Elie Haddad <elie@example.com>"},
                    {"name": "To",
                     "value": "ahmed.alrajeh@alrugaibfurniture.com"},
                    {"name": "Cc", "value": "sara@example.com"},
                    {"name": "Subject", "value": "Statue quote"},
                    {"name": "Date", "value": "Sat, 12 Jul 2026 14:02:11 +0300"},
                    {"name": "Message-ID", "value": "<m1@example.com>"},
                ],
                "parts": [
                    {"mimeType": "text/plain", "filename": "",
                     "body": {"size": 14, "data": _b64u("Hi Ahmed, see")}},
                    {"mimeType": "text/html", "filename": "",
                     "body": {"data": _b64u(
                         '<div onclick="steal()">Hi <b>Ahmed</b>'
                         '<script>alert(1)</script>'
                         '<img src="cid:logo1"><img src="https://track.me/p.gif">'
                         '<a href="javascript:evil()">x</a>'
                         '<a href="https://reval.studio">site</a></div>')}},
                    {"mimeType": "image/png", "filename": "logo.png",
                     "headers": [{"name": "Content-ID", "value": "<logo1>"}],
                     "body": {"attachmentId": "ALOGO", "size": len(PNG)}},
                    {"mimeType": "application/pdf", "filename": "quote_v2.pdf",
                     "body": {"attachmentId": "APDF", "size": len(PDF)}},
                ],
            },
        },
    ],
}


class _Exec:
    def __init__(self, data):
        self._d = data

    def execute(self):
        return self._d


class _Atts:
    def get(self, userId=None, messageId=None, id=None):
        blob = {"ALOGO": PNG, "APDF": PDF}[id]
        return _Exec({"size": len(blob), "data": _b64u(blob)})


class _Messages:
    def __init__(self, svc):
        self.svc = svc

    def list(self, userId=None, q=None, maxResults=None):
        self.svc.queries.append(q)
        return _Exec({"messages": [{"id": "M2", "threadId": "T1"}]})

    def get(self, userId=None, id=None, format=None, metadataHeaders=None):
        for m in THREAD["messages"]:
            if m["id"] == id:
                return _Exec(m)
        raise RuntimeError(f"no message {id}")

    def attachments(self):
        return _Atts()

    def send(self, userId=None, body=None):
        self.svc.sent.append(body)
        return _Exec({"id": "SENT1"})


class _Threads:
    def get(self, userId=None, id=None, format=None):
        if id != THREAD["id"]:
            raise RuntimeError("not a thread id")   # forces the msg-id fallback
        return _Exec(THREAD)


class _Users:
    def __init__(self, svc):
        self.svc = svc

    def messages(self):
        return _Messages(self.svc)

    def threads(self):
        return _Threads()

    def getProfile(self, userId=None):
        return _Exec({"emailAddress": "ahmed.alrajeh@alrugaibfurniture.com"})


class FakeGmail:
    def __init__(self):
        self.sent = []
        self.queries = []

    def users(self):
        return _Users(self)


# "now" is pinned so date_human is deterministic forever (Wed 15 Jul 2026,
# three days after Elie's message).
NOW = datetime(2026, 7, 15, 10, 0, tzinfo=timezone(timedelta(hours=3)))


def fresh(monkeypatch, tmp_path=None):
    """Fake Gmail service in the account cache + captured HUD events."""
    svc = FakeGmail()
    gmail_control._SVC["work"] = (svc, "ahmed.alrajeh@alrugaibfurniture.com")
    mail_view._THREADS.clear()
    mail_view._ORDER.clear()
    events = []
    monkeypatch.setattr(mail_view, "emit",
                        lambda etype, **data: events.append((etype, data)))
    monkeypatch.setattr(mail_view, "_now", lambda: NOW)
    monkeypatch.setenv("EMAIL_ADDRESS", "ahmad@revalstudio.com")
    monkeypatch.setattr(mail_view, "_account", lambda a: "work")
    if tmp_path is not None:
        monkeypatch.setattr(mail_view, "ATT_ROOT", tmp_path / "attachments")
    return svc, events


# ---------- gmail thread -> the email_thread event ----------

def test_thread_event_is_oldest_first_with_outgoing_and_attachments(monkeypatch):
    _svc, events = fresh(monkeypatch)
    out = mail_view.show("from:elie", "work")

    assert events[0][0] == "email_thread"
    ev = events[0][1]
    assert ev["thread_id"] == "T1" and ev["account"] == "work"
    assert ev["subject"] == "Statue quote"

    m1, m2 = ev["messages"]                       # OLDEST FIRST
    assert (m1["msg_id"], m2["msg_id"]) == ("M1", "M2")
    assert m1["from_name"] == "Elie Haddad"
    assert m1["from_addr"] == "elie@example.com"
    assert m1["cc"] == "sara@example.com"
    assert m1["outgoing"] is False
    assert m2["outgoing"] is True                 # Gmail's SENT label
    assert m1["date"] == "2026-07-12T14:02:11+03:00"    # Riyadh offset
    assert m1["date_human"] == "Sun 14:02"              # NOW = Wed 15 Jul
    assert m1["body_text"].startswith("Hi Ahmed")
    # the PDF is a real attachment; the cid: logo is NOT (it's inlined)
    assert [(a["filename"], a["mime"], a["size"], a["path"])
            for a in m1["attachments"]] == [
        ("quote_v2.pdf", "application/pdf", len(PDF), "")]
    assert m1["attachments"][0]["att_id"] == "APDF"
    assert m2["attachments"] == []
    # plain-text message: no html, body kept
    assert m2["body_html"] == "" and m2["body_text"] == "Looks good"
    assert "2 messages" in out and "1 attachment" in out


def test_date_human_is_riyadh_and_pre_formatted(monkeypatch):
    """The HUD just prints date_human — every bucket is formatted here."""
    monkeypatch.setattr(mail_view, "_now", lambda: NOW)      # Wed 15 Jul 2026
    utc = timezone.utc
    # same day (08:00 UTC = 11:00 Riyadh) -> just the clock
    assert mail_view._human(datetime(2026, 7, 15, 8, 0, tzinfo=utc)) == "11:00"
    # this week -> weekday + clock
    assert mail_view._human(
        datetime(2026, 7, 12, 11, 2, 11, tzinfo=utc)) == "Sun 14:02"
    # this year -> day + month
    assert mail_view._human(
        datetime(2026, 2, 3, 6, 30, tzinfo=utc)) == "03 Feb 09:30"
    # older -> add the year
    assert mail_view._human(
        datetime(2024, 12, 1, 6, 0, tzinfo=utc)) == "01 Dec 2024"


def test_show_id_resolves_a_message_id_to_its_thread(monkeypatch):
    """The brain chains off gmail_search (a MESSAGE id) — threads().get 404s on
    it, so we resolve threadId first and still get the whole thread."""
    _svc, events = fresh(monkeypatch)
    mail_view.show_id("M2", "work")
    ev = events[0][1]
    assert ev["thread_id"] == "T1"
    assert [m["msg_id"] for m in ev["messages"]] == ["M1", "M2"]


# ---------- HTML sanitizer (§5) ----------

def test_sanitizer_strips_script_handlers_and_blocks_remote_images(monkeypatch):
    _svc, events = fresh(monkeypatch)
    mail_view.show("from:elie", "work")
    html = events[0][1]["messages"][0]["body_html"]

    assert "<script" not in html and "alert(1)" not in html
    assert "onclick" not in html
    assert "javascript:" not in html
    assert "track.me" not in html                       # remote img blocked
    assert html.count("data:image/gif;base64,R0lGOD") == 1   # → the pixel
    # the cid: logo is inlined from the message's own bytes
    assert f"data:image/png;base64,{base64.b64encode(PNG).decode()}" in html
    assert '<a href="https://reval.studio">' in html   # real links survive
    assert "<b>Ahmed</b>" in html                      # formatting survives
    assert events[0][1]["messages"][0]["images_blocked"] == 1


def test_sanitizer_units():
    inline = {"c1": {"mime": "image/png", "b64": "AAA"}}
    html, blocked = mail_view.sanitize_html(
        '<p style="background:url(https://t.io/x.png)">hi</p>'
        '<iframe src="https://evil.io"></iframe>'
        '<img SRC="cid:c1"><img src="java\nscript:alert(1)">'
        '<style>a{color:red} b{background:url(https://t.io/y.png)}</style>',
        inline, block_remote=True)
    assert "<iframe" not in html and "evil.io" not in html
    assert 'src="data:image/png;base64,AAA"' in html
    assert "alert(1)" not in html
    assert "url(about:blank)" in html          # inline style AND <style> block
    assert "a{color:red}" in html              # CSS itself is not escaped
    assert blocked == 0                        # css urls aren't <img> blocks

    # images unblocked: the remote src comes back untouched
    html2, blocked2 = mail_view.sanitize_html(
        '<img src="https://track.me/p.gif">', {}, block_remote=False)
    assert 'src="https://track.me/p.gif"' in html2 and blocked2 == 0


def test_load_images_reemits_with_remote_srcs(monkeypatch):
    _svc, events = fresh(monkeypatch)
    mail_view.show("from:elie", "work")
    assert "track.me" not in events[0][1]["messages"][0]["body_html"]

    w = make_watcher()
    asyncio.run(w._email_action({"thread_id": "T1", "account": "work",
                                 "action": "images", "msg_id": "M1"}))
    ev = [e for e in events if e[0] == "email_thread"][-1][1]
    assert 'src="https://track.me/p.gif"' in ev["messages"][0]["body_html"]
    assert ev["messages"][0]["images_blocked"] == 0


# ---------- HUD actions (the Swift card's email_action.json shapes) ----------

def test_email_action_attachment_downloads_and_emits_path(monkeypatch, tmp_path):
    _svc, events = fresh(monkeypatch, tmp_path)
    mail_view.show("from:elie", "work")
    w = make_watcher()
    asyncio.run(w._email_action({
        "thread_id": "T1", "account": "work", "action": "attachment",
        "msg_id": "M1", "att_id": "APDF", "filename": "quote_v2.pdf"}))

    ev = [e for e in events if e[0] == "email_attachment"][-1][1]
    assert ev["ok"] is True and ev["error"] == ""
    assert ev["thread_id"] == "T1" and ev["msg_id"] == "M1"
    assert ev["path"].endswith("/T1/quote_v2.pdf")
    with open(ev["path"], "rb") as fh:                 # really on disk
        assert fh.read() == PDF
    # a re-emit of the thread now carries the local path on the chip
    mail_view.emit_thread("T1")
    att = [e for e in events if e[0] == "email_thread"][-1][1][
        "messages"][0]["attachments"][0]
    assert att["path"] == ev["path"]


def test_attachment_filename_cannot_escape_the_dir(monkeypatch, tmp_path):
    _svc, _events = fresh(monkeypatch, tmp_path)
    assert mail_view.safe_name("../../../etc/passwd") == "passwd"
    assert mail_view.safe_name("a/b/../c.pdf") == "c.pdf"
    assert mail_view.safe_name("") == "attachment"
    p = mail_view.dest_path("T1", "../../etc/passwd")
    assert p.parent == mail_view.ATT_ROOT / "T1" and p.name == "passwd"


def test_email_action_reply_sends_in_thread_and_tells_jarvis(monkeypatch):
    svc, events = fresh(monkeypatch)
    mail_view.show("from:elie", "work")
    w = make_watcher()
    asyncio.run(w._email_action({
        "thread_id": "T1", "account": "work", "action": "reply",
        "msg_id": "M1", "body": "Sounds good, sending the PO."}))

    body = svc.sent[-1]
    assert body["threadId"] == "T1"                    # stays in the thread
    raw = base64.urlsafe_b64decode(body["raw"] + "==").decode()
    assert "To: Elie Haddad <elie@example.com>" in raw
    assert "Subject: Re: Statue quote" in raw
    assert "In-Reply-To: <m1@example.com>" in raw      # threading preserved
    assert "References: <m1@example.com>" in raw
    assert "Sounds good, sending the PO." in raw
    assert ("email_reply_sent", {"thread_id": "T1", "ok": True,
                                 "error": ""}) in events
    assert any("[mail] Ahmed sent a reply" in n for n in w.app.inbox.notes)


def test_reply_all_keeps_the_other_recipients(monkeypatch):
    svc, _events = fresh(monkeypatch)
    mail_view.show("from:elie", "work")
    w = make_watcher()
    asyncio.run(w._email_action({
        "thread_id": "T1", "account": "work", "action": "reply_all",
        "msg_id": "M1", "body": "All: noted."}))
    raw = base64.urlsafe_b64decode(svc.sent[-1]["raw"] + "==").decode()
    assert "Cc: sara@example.com" in raw               # kept
    assert "ahmed.alrajeh@alrugaibfurniture.com" not in raw.split("\n\n")[0]


def test_reply_failure_is_reported_not_swallowed(monkeypatch):
    _svc, events = fresh(monkeypatch)
    mail_view.show("from:elie", "work")

    def boom(label, mid, body, reply_all=False):
        raise RuntimeError("gmail down")

    monkeypatch.setattr(gmail_control, "_reply", boom)
    w = make_watcher()
    asyncio.run(w._email_action({
        "thread_id": "T1", "account": "work", "action": "reply",
        "msg_id": "M1", "body": "hi"}))
    ev = [e for e in events if e[0] == "email_reply_sent"][-1][1]
    assert ev["ok"] is False and "gmail down" in ev["error"]
    assert any("FAILED" in n for n in w.app.inbox.notes)


def test_email_action_open_attachment_opens_the_file(monkeypatch, tmp_path):
    _svc, _events = fresh(monkeypatch, tmp_path)
    opened = []
    monkeypatch.setattr(mail_view.subprocess, "Popen",
                        lambda argv, **kw: opened.append(argv))
    f = tmp_path / "quote.pdf"
    f.write_bytes(PDF)
    w = make_watcher()
    asyncio.run(w._email_action({"thread_id": "T1", "action": "open_attachment",
                                 "path": str(f)}))
    assert opened and str(f) in opened[-1]
    # a path that isn't there is a no-op, never a crash
    opened.clear()
    asyncio.run(w._email_action({"thread_id": "T1", "action": "open_attachment",
                                 "path": str(tmp_path / "gone.pdf")}))
    assert opened == []


def test_hud_close_drops_state_silently(monkeypatch):
    """Ahmed hit ✕: the HUD already dropped the card, so the engine just forgets
    the thread — no card_close bounced back at it, no speech."""
    _svc, events = fresh(monkeypatch)
    mail_view.show("from:elie", "work")
    w = make_watcher()
    asyncio.run(w._email_action({"thread_id": "T1", "action": "close"}))
    assert "T1" not in mail_view._THREADS
    assert all(e[0] != "card_close" for e in events)
    assert w.app.inbox.notes == []


def test_mail_close_tool_dismisses_the_card(monkeypatch):
    """Jarvis closing it on Ahmed's word (the mail_close MCP tool) DOES emit
    card_close — the HUD hasn't closed anything yet."""
    _svc, events = fresh(monkeypatch)
    mail_view.show("from:elie", "work")
    assert mail_view.latest_id() == "T1"
    mail_view.close()                       # no id = the open card
    assert ("card_close", {"draft_id": "T1"}) in events
    assert "T1" not in mail_view._THREADS


def test_unknown_thread_action_is_a_noop(monkeypatch):
    """A stale card (engine restarted) must not crash or speak."""
    _svc, _events = fresh(monkeypatch)
    w = make_watcher()
    asyncio.run(w._email_action({"thread_id": "GONE", "action": "images",
                                 "msg_id": "X"}))
    asyncio.run(w._email_action({"thread_id": "GONE", "action": "close"}))
    assert w.app.inbox.notes == []


# ---------- IMAP side (business mailbox) ----------

def _imap_msg() -> EmailMessage:
    m = EmailMessage()
    m["From"] = "Elie <elie@example.com>"
    m["To"] = "ahmad@revalstudio.com"
    m["Subject"] = "Invoice"
    m["Date"] = "Sat, 12 Jul 2026 09:00:00 +0300"
    m["Message-ID"] = "<abc@example.com>"
    m.set_content("Plain body")
    m.add_alternative("<p>HTML <b>body</b></p>", subtype="html")
    m.add_attachment(PDF, maintype="application", subtype="pdf",
                     filename="inv.pdf")
    return m


def test_imap_message_parts_are_untruncated_with_attachments():
    parts = email_control._parts_of(_imap_msg())
    assert parts["body_text"].strip() == "Plain body"
    assert "<b>body</b>" in parts["body_html"]
    atts = parts["attachments"]
    assert len(atts) == 1
    assert atts[0]["filename"] == "inv.pdf"
    assert atts[0]["mime"] == "application/pdf"
    assert atts[0]["size"] == len(PDF)
    assert atts[0]["att_id"].isdigit()          # part index — IMAP has no ids


def test_imap_root_id_and_ident_split():
    m = _imap_msg()
    assert email_control._root_id(m) == "<abc@example.com>"   # it started it
    m2 = _imap_msg()
    m2.replace_header("Message-ID", "<def@example.com>")
    m2["References"] = "<abc@example.com> <ccc@example.com>"
    assert email_control._root_id(m2) == "<abc@example.com>"  # thread root
    assert email_control._split_ident("Sent|42") == ("Sent", "42")
    assert email_control._split_ident("42") == ("INBOX", "42")


def test_imap_reply_sets_threading_headers(monkeypatch):
    import smtplib
    sent = []

    class FakeSMTP:
        def __init__(self, host, port, timeout=None):
            pass

        def __enter__(self):
            return self

        def __exit__(self, *a):
            return False

        def login(self, user, pw):
            pass

        def send_message(self, msg, to_addrs=None):
            sent.append((msg, to_addrs))

    monkeypatch.setattr(smtplib, "SMTP_SSL", FakeSMTP)
    monkeypatch.setenv("EMAIL_ADDRESS", "ahmad@revalstudio.com")
    monkeypatch.setenv("EMAIL_PASSWORD", "pw")
    monkeypatch.setattr(email_control, "_imap", lambda: _FakeIMAP())
    monkeypatch.setattr(email_control, "_fetch",
                        lambda m, folder, uid: _imap_msg())

    out = email_control.reply("INBOX|7", "On it.")
    msg, rcpts = sent[-1]
    assert msg["To"] == "Elie <elie@example.com>"
    assert msg["Subject"] == "Re: Invoice"
    assert msg["In-Reply-To"] == "<abc@example.com>"
    assert "<abc@example.com>" in msg["References"]
    assert rcpts == ["elie@example.com"]
    assert "Replied to" in out


class _FakeIMAP:
    def select(self, folder):
        return ("OK", [b"1"])

    def logout(self):
        pass
