"""Compose/confirm card tests — the draft engine (voice/compose.py) and the
HUD→engine contract (control/card_action.json, applied by
ControlWatcher._card_action). The JSON payloads here are EXACTLY what the Mac
HUD's ComposePanel writes (hud/ClaudeHUD.swift), so these tests pin the
contract between the Swift card and the Python engine. No SMTP, no Google
API, no HUD, no audio (fakes throughout).

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

import asyncio

import voice.compose as compose
import voice.control as control
from voice import email_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 (no control/ dir wipe,
    no subprocesses) — just enough to drive _card_action."""
    w = control.ControlWatcher.__new__(control.ControlWatcher)
    w.app = FakeApp()
    return w


def fresh(monkeypatch):
    """Empty draft store + captured HUD events. Returns the event list."""
    compose._DRAFTS.clear()
    compose._ORDER.clear()
    events = []
    monkeypatch.setattr(compose, "emit",
                        lambda etype, **data: events.append((etype, data)))
    return events


# ---------- card creation (engine → HUD) ----------

def test_email_draft_pops_card(monkeypatch):
    events = fresh(monkeypatch)
    did = compose.create_email_draft(
        "elie@revalstudio.com", "", "Q3 numbers", "Elie,\n\nNumbers attached.")
    assert compose.get_draft(did)["kind"] == "email"
    assert compose.latest_id() == did
    # the HUD card event carries every field, no SMTP involved
    assert events == [("compose", {
        "draft_id": did, "to": "elie@revalstudio.com", "cc": "",
        "subject": "Q3 numbers", "body": "Elie,\n\nNumbers attached.",
        "attachments": []})]


def test_email_draft_with_attachments_emits_them(monkeypatch):
    """email_compose staged files → the compose event shows them on the card.
    Accepts a real list or the tool's comma-separated string form."""
    events = fresh(monkeypatch)
    did = compose.create_email_draft(
        "elie@x.com", "", "Report", "Attached.",
        ["/tmp/q3.pdf", "/tmp/chart.png"])
    assert events[0][1]["attachments"] == ["/tmp/q3.pdf", "/tmp/chart.png"]
    assert compose.get_draft(did)["fields"]["attachments"] == [
        "/tmp/q3.pdf", "/tmp/chart.png"]

    did2 = compose.create_email_draft(
        "elie@x.com", "", "Report", "Attached.", " /tmp/a.pdf , /tmp/b.xlsx ")
    assert compose.get_draft(did2)["fields"]["attachments"] == [
        "/tmp/a.pdf", "/tmp/b.xlsx"]


def test_event_draft_pops_card(monkeypatch):
    events = fresh(monkeypatch)
    did = compose.create_event_draft(
        "Design review", "2026-07-14T15:00:00", "2026-07-14T15:30:00",
        "sara@x.com", True, ["09:00  Standup"])
    assert events[0][0] == "event_card"
    assert events[0][1]["meet"] is True
    assert events[0][1]["context"] == ["09:00  Standup"]
    assert compose.latest_id("event") == did


# ---------- HUD button clicks (the Swift card's card_action.json shapes) ----

def test_hud_send_uses_edited_fields(monkeypatch):
    """Ahmed edits the card, clicks Send → SMTP gets HIS values, card closes,
    Jarvis gets a note."""
    events = fresh(monkeypatch)
    sent = {}

    def fake_send(to, subject, body, cc):
        sent.update(to=to, subject=subject, body=body, cc=cc)
        return f"Sent '{subject}' to {to}."

    monkeypatch.setattr(email_control, "_send", fake_send)
    did = compose.create_email_draft("wrong@x.com", "", "Draft subj", "Draft body")
    w = make_watcher()
    # exactly what ComposePanel.send() writes to control/card_action.json
    asyncio.run(w._card_action({
        "kind": "email", "draft_id": did, "action": "send",
        "fields": {"to": "right@x.com", "cc": "boss@x.com",
                   "subject": "Fixed subj", "body": "Fixed body"}}))
    assert sent == {"to": "right@x.com", "subject": "Fixed subj",
                    "body": "Fixed body", "cc": "boss@x.com"}
    assert compose.get_draft(did) is None                 # draft consumed
    assert ("card_close", {"draft_id": did}) in events    # card dismissed
    assert any("[compose] Ahmed clicked Send" in n
               for n in w.app.inbox.notes)


def test_hud_cancel_and_close(monkeypatch):
    events = fresh(monkeypatch)
    w = make_watcher()

    did = compose.create_email_draft("a@x.com", "", "s", "b")
    asyncio.run(w._card_action(
        {"kind": "email", "draft_id": did, "action": "cancel"}))
    assert compose.get_draft(did) is None
    assert any("cancelled" in n for n in w.app.inbox.notes)

    did2 = compose.create_email_draft("a@x.com", "", "s", "b")
    asyncio.run(w._card_action(
        {"kind": "email", "draft_id": did2, "action": "close"}))
    assert compose.get_draft(did2) is None
    assert any("dismissed" in n for n in w.app.inbox.notes)
    # both paths closed the HUD card
    assert [e for e in events if e[0] == "card_close"] == [
        ("card_close", {"draft_id": did}), ("card_close", {"draft_id": did2})]


def test_hud_edit_is_silent_then_spoken_send_uses_it(monkeypatch):
    """Typing in the card (debounced action 'edit') stores silently — no
    chatter — and a later spoken "send it" (compose.send_draft) sends the
    edited version."""
    fresh(monkeypatch)
    sent = {}

    def fake_send(to, subject, body, cc):
        sent.update(to=to, subject=subject)
        return "ok"

    monkeypatch.setattr(email_control, "_send", fake_send)
    did = compose.create_email_draft("old@x.com", "", "old subj", "body")
    w = make_watcher()
    asyncio.run(w._card_action({
        "kind": "email", "draft_id": did, "action": "edit",
        "fields": {"to": "new@x.com", "cc": "", "subject": "new subj",
                   "body": "body"}}))
    assert w.app.inbox.notes == []                       # silent — no speech
    assert compose.get_draft(did)["fields"]["to"] == "new@x.com"
    ok, out = asyncio.run(compose.send_draft(did))       # spoken "send it"
    assert ok and sent == {"to": "new@x.com", "subject": "new subj"}


def test_event_send_merges_meet_toggle(monkeypatch):
    """Event card: Send folds in edits including the Meet switch (False must
    stick — it's a real value, not a missing one)."""
    fresh(monkeypatch)
    booked = {}
    monkeypatch.setattr(compose, "_create_event",
                        lambda f: booked.update(f) or "Event booked.")
    did = compose.create_event_draft(
        "Sync", "2026-07-14T15:00:00", "", "", True, [])
    w = make_watcher()
    asyncio.run(w._card_action({
        "kind": "event", "draft_id": did, "action": "send",
        "fields": {"title": "Sync (moved)", "start": "2026-07-14T16:00:00",
                   "end": "", "attendees": "sara@x.com", "meet": False}}))
    assert booked["title"] == "Sync (moved)"
    assert booked["meet"] is False
    assert booked["attendees"] == "sara@x.com"
    assert compose.get_draft(did) is None


def test_send_failure_keeps_card_open(monkeypatch):
    """SMTP blowing up must not eat the draft: send_draft reports the failure
    and the card stays for another try."""
    events = fresh(monkeypatch)

    def boom(to, subject, body, cc):
        raise RuntimeError("smtp down")

    monkeypatch.setattr(email_control, "_send", boom)
    did = compose.create_email_draft("a@x.com", "", "s", "b")
    ok, out = asyncio.run(compose.send_draft(did))
    assert not ok and "smtp down" in out
    assert compose.get_draft(did) is not None            # still open
    assert all(e[0] != "card_close" for e in events)


def test_stale_or_unknown_draft_is_ignored(monkeypatch):
    """A card_action for a draft that no longer exists (double click, restart)
    must be a no-op — no crash, no speech."""
    fresh(monkeypatch)
    w = make_watcher()
    asyncio.run(w._card_action({
        "kind": "email", "draft_id": "deadbeef", "action": "send",
        "fields": {"to": "a@x.com"}}))
    assert w.app.inbox.notes == []


# ---------- attachments (staged by Jarvis, edited by Ahmed, sent by SMTP) ----

def test_send_draft_passes_attachments_to_send(monkeypatch):
    """A draft holding attachments hands them to email_control._send; a draft
    without any uses the exact legacy 4-arg call (old fakes keep working)."""
    fresh(monkeypatch)
    calls = []

    def fake_send(to, subject, body, cc, attachments=None):
        calls.append({"to": to, "attachments": attachments})
        return "ok"

    monkeypatch.setattr(email_control, "_send", fake_send)
    did = compose.create_email_draft(
        "a@x.com", "", "s", "b", ["/tmp/report.pdf"])
    ok, _ = asyncio.run(compose.send_draft(did))
    assert ok and calls[-1]["attachments"] == ["/tmp/report.pdf"]

    def legacy_send(to, subject, body, cc):   # 4-arg fake, no attachments kw
        calls.append({"to": to, "attachments": "legacy"})
        return "ok"

    monkeypatch.setattr(email_control, "_send", legacy_send)
    did2 = compose.create_email_draft("a@x.com", "", "s", "b")
    ok, _ = asyncio.run(compose.send_draft(did2))
    assert ok and calls[-1]["attachments"] == "legacy"


def test_hud_edit_merges_attachments_field(monkeypatch):
    """The HUD's card_action fields.attachments (Finder-picked / dropped /
    chip-removed files) merges into the draft like to/cc/subject/body: a list
    is stored as-is, a comma string is coerced, [] clears the staged files."""
    fresh(monkeypatch)
    w = make_watcher()
    did = compose.create_email_draft(
        "a@x.com", "", "s", "b", ["/tmp/original.pdf"])

    # exactly what ComposePanel writes after Ahmed drops a file on the card
    asyncio.run(w._card_action({
        "kind": "email", "draft_id": did, "action": "edit",
        "fields": {"to": "a@x.com", "cc": "", "subject": "s", "body": "b",
                   "attachments": ["/tmp/original.pdf", "/tmp/added.png"]}}))
    assert w.app.inbox.notes == []                       # silent, like typing
    assert compose.get_draft(did)["fields"]["attachments"] == [
        "/tmp/original.pdf", "/tmp/added.png"]

    # a comma-separated string is normalized too
    asyncio.run(w._card_action({
        "kind": "email", "draft_id": did, "action": "edit",
        "fields": {"attachments": "/tmp/x.txt, /tmp/y.txt"}}))
    assert compose.get_draft(did)["fields"]["attachments"] == [
        "/tmp/x.txt", "/tmp/y.txt"]

    # Ahmed removed every chip → [] must actually clear them (not be skipped)
    asyncio.run(w._card_action({
        "kind": "email", "draft_id": did, "action": "edit",
        "fields": {"attachments": []}}))
    assert compose.get_draft(did)["fields"]["attachments"] == []


def test_hud_send_carries_attachments(monkeypatch):
    """Send from the HUD: the fields' attachment list (after Ahmed's add/
    remove) is what SMTP gets."""
    fresh(monkeypatch)
    sent = {}

    def fake_send(to, subject, body, cc, attachments=None):
        sent.update(to=to, attachments=attachments)
        return "ok"

    monkeypatch.setattr(email_control, "_send", fake_send)
    did = compose.create_email_draft("a@x.com", "", "s", "b", ["/tmp/old.pdf"])
    w = make_watcher()
    asyncio.run(w._card_action({
        "kind": "email", "draft_id": did, "action": "send",
        "fields": {"to": "a@x.com", "cc": "", "subject": "s", "body": "b",
                   "attachments": ["/tmp/final.pdf"]}}))
    assert sent == {"to": "a@x.com", "attachments": ["/tmp/final.pdf"]}
    assert compose.get_draft(did) is None


def test_send_builds_mime_attachments(monkeypatch, tmp_path):
    """email_control._send with attachments: real file → an application/
    octet-stream part with Content-Disposition attachment; missing file →
    skipped + noted, never fatal. No attachments → NOT multipart (legacy
    message untouched). SMTP is faked — nothing leaves the machine."""
    import smtplib

    sent_msgs = []

    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_msgs.append(msg)

    monkeypatch.setattr(smtplib, "SMTP_SSL", FakeSMTP)
    monkeypatch.setenv("EMAIL_ADDRESS", "jarvis@x.com")
    monkeypatch.setenv("EMAIL_PASSWORD", "pw")

    f = tmp_path / "q3 report.pdf"
    f.write_bytes(b"%PDF-fake")
    out = email_control._send("a@x.com", "Numbers", "See attached.", "",
                              [str(f), str(tmp_path / "missing.bin")])
    msg = sent_msgs[-1]
    assert msg.is_multipart()
    parts = [p for p in msg.iter_attachments()]
    assert len(parts) == 1
    assert parts[0].get_content_type() == "application/octet-stream"
    assert parts[0].get_filename() == "q3 report.pdf"
    assert "attachment" in str(parts[0].get("Content-Disposition"))
    assert parts[0].get_payload(decode=True) == b"%PDF-fake"
    assert "1 attachment" in out and "missing.bin" in out

    # no attachments → single-part message, summary line unchanged
    out2 = email_control._send("a@x.com", "Numbers", "Body.", "")
    assert not sent_msgs[-1].is_multipart()
    assert out2 == "Sent 'Numbers' to a@x.com."
