#!/usr/bin/env python3
"""Matrix intake bot — entrypoint.

A top-level message in the dedicated intake room is parsed (local Qwen via Spark Control)
into a proposed fundraising-grid add/edit and posted back IN A THREAD. The team member
replies in that thread — **yes** / **edit field=value** / **no** — and only on **yes** does
the bot write, through the CRM's own API. Nothing is ever written autonomously.

Runs as its own process (its matrix-nio dep is isolated here, never in the CRM runtime).
Lifts matrix-bridge's prime-then-listen + threaded-reply plumbing. Config: repo .env.
"""
import asyncio
import base64

from nio import AsyncClient, MatrixRoom, MessageDirection, RoomMessageImage, RoomMessageText

import crm_client
import email_proposals
import matrix_io
import parse
import proposals
import query
import settings
import spark

UNCLEAR_HELP = (
    "🤔 I couldn't tell what to record. Try e.g.\n"
    "`New investor: Acme Capital — Jane Doe <jane@acme.com>, met at the Austin conf`\n"
    "or a note like `Note for Acme Capital: wants the Q3 deck, follow up next week`."
)

EMAIL_POLL_SEC = 20  # how often the bot polls the CRM for new/decided email-activity proposals
MAX_THREAD_SCAN_PAGES = 8  # how far back to scan for a resolved thread's replies before redacting


async def main():
    mx = settings.matrix_settings()
    client = AsyncClient(mx["homeserver"], mx["user_id"])
    client.restore_login(user_id=mx["user_id"], device_id=mx["device_id"], access_token=mx["token"])
    say = matrix_io.make_say(client)
    nudge = matrix_io.make_reply(client)
    store = proposals.ProposalStore()
    intake_room = mx["intake_room"]
    roster = settings.team_roster()  # frames the parse: teammates do outreach, aren't prospects
    if roster:
        print(f"matrix-intake: team roster loaded ({len(roster)} names)", flush=True)
    review_room = settings.email_review_room()  # CRM-drafted email proposals (empty → feature off)
    query_room = settings.query_room()  # dedicated read-only Q&A room (empty → use the intake trigger)
    email_threads = {}  # Matrix thread-root event_id -> {id, investor_name, note} for an email proposal

    async def handle_intake(room_id, root, text, source="matrix_intake"):
        # `source` tags provenance for the eventual commit: "matrix_intake" for a typed note,
        # "matrix_card" when the text came from a scanned business card (on_image). Everything
        # else about the flow is identical — that's the whole point of transcribe-then-reuse.
        # A bare yes/no/approve typed in the MAIN timeline (not inside a proposal's thread) is
        # an easy slip — point the user back to the thread rather than parse it as a new intake.
        action, _ = proposals.interpret_reply(text)
        if action in ("approve", "reject") and store.any_pending():
            await nudge(room_id, "👉 To approve, reject, or edit a proposal, open its **thread** "
                                 "and reply there — the note is in the thread.", root)
            return
        try:
            proposal = await asyncio.to_thread(parse.parse_message, text, roster=roster)
        except Exception as exc:  # Spark/Qwen unreachable or bad response
            await say(room_id, f"⚠️ couldn't reach the local parser: {str(exc)[:200]}", root)
            return
        if proposal["intent"] == "unclear":
            await say(room_id, UNCLEAR_HELP, root)
            return
        proposal["_source"] = source  # rides through to commit (control key, survives dict() copies)
        # Resolve new-vs-existing against the CRM matcher (read-only). Degrade gracefully if the
        # CRM is unreachable — still propose as new, just without match/candidate hints.
        match, candidates = None, []
        try:
            res = await asyncio.to_thread(crm_client.match, proposal)
            match = res.get("match")
            candidates = res.get("candidates") or []
        except Exception:
            pass
        if match:
            # Confident exact match → auto-attach the note to that investor (no disambiguation).
            proposal["intent"] = "meeting_note"
            proposal["_match_id"] = match["id"]
            proposal["_stage"] = "approval"
            store.put(root, proposal)
            hint = (f"\n\n🔎 Looks like an existing investor: **{match['name']}** — "
                    "this will append a note to them.")
            await say(room_id, proposals.render(proposal) + hint, root)
            await nudge(room_id, proposals.summary_line(proposal), root)
            return
        if candidates:
            # No exact match but near-misses exist → make the human pick one or confirm "new",
            # so a typo'd/near-duplicate name can't silently create a second investor.
            proposal["_stage"] = "disambiguate"
            proposal["_candidates"] = candidates
            store.put(root, proposal)
            await say(room_id, proposals.render_disambiguation(proposal), root)
            await nudge(room_id, proposals.disambiguation_nudge(proposal), root)
            return
        # Genuinely new — straight to the new-investor approval card.
        proposal["_stage"] = "approval"
        store.put(root, proposal)
        await say(room_id, proposals.render(proposal), root)
        # Also drop a brief, un-threaded reply in the main timeline so the proposal isn't
        # easy to miss inside a thread (the full card + yes/edit/no stay in the thread).
        await nudge(room_id, proposals.summary_line(proposal), root)

    async def handle_card(room_id, event):
        """A photo in the intake room → transcribe the business card on the local VL model, then
        hand the transcription to the SAME intake flow as a typed note (parse → match → approve).
        The only new step is image → text; everything downstream is reused. The transcription is
        also the source text the email-integrity check runs against, so a mis-read address can't
        slip in unapproved."""
        mxc = getattr(event, "url", None)
        if not mxc:
            # Unencrypted images carry a plain mxc:// url; an encrypted room delivers a different
            # event class entirely (we don't register for it), so this only guards the odd case.
            await say(room_id, "📇 I can only read unencrypted images right now.", event.event_id)
            return
        await say(room_id, "📇 Reading the card…", event.event_id)  # vision is slower — ack first
        try:
            resp = await client.download(mxc=mxc)
            data = getattr(resp, "body", None)
            if not isinstance(data, (bytes, bytearray)):  # a DownloadError carries no bytes
                raise RuntimeError(getattr(resp, "message", None) or "image download failed")
            mime = getattr(resp, "content_type", None) or "image/jpeg"
            b64 = base64.b64encode(data).decode("ascii")
            text = await asyncio.to_thread(spark.transcribe_card, b64, mime)
        except Exception as exc:
            await say(room_id, f"⚠️ couldn't read the card: {str(exc)[:200]}", event.event_id)
            return
        if len(text.strip()) < 5:
            await say(room_id, "📇 I couldn't read any text on that card — try a clearer, "
                               "well-lit photo taken straight-on.", event.event_id)
            return
        # Frame the raw transcription so the existing extractor reads it as a new-investor intake;
        # the transcription itself is what email-integrity is checked against.
        framed = "New investor — from a business card:\n" + text.strip()
        await handle_intake(room_id, event.event_id, framed, source="matrix_card")

    async def handle_query(room_id, root, question):
        """A read-only NL question ('@bot …' / '?…') — translate + run it on the BOX (local Qwen,
        nothing leaves the box) and post the answer in a thread. No write path, no approval gate:
        it only reads curated, parameterized queries. The endpoint returns its structured result
        even on a soft no-match / model-down, so we render that; a transport/auth failure raises
        and we show a brief error."""
        try:
            result = await asyncio.to_thread(crm_client.nl_query, question)
        except Exception as exc:
            await say(room_id, f"⚠️ couldn't run that query: {str(exc)[:200]}", root)
            return
        await say(room_id, query.render_answer(result), root)

    async def handle_reply(room_id, root, text):
        # Claim the proposal synchronously — BEFORE any await — so a second reply that
        # arrives while a commit is in flight can't double-process it. asyncio is
        # cooperative: nothing else runs between here and the first await below, so the
        # pop is atomic w.r.t. other Matrix events.
        proposal = store.pop(root)
        if proposal is None:
            return
        if proposal.get("_stage") == "disambiguate":
            await handle_disambiguation(room_id, root, text, proposal)
            return

        action, payload = proposals.interpret_reply(text)
        if action == "approve":
            try:
                summary = await asyncio.to_thread(crm_client.commit, proposal)
            except Exception as exc:
                store.put(root, proposal)  # commit failed — restore so the user can retry
                await say(room_id, f"⚠️ write failed, nothing committed: {exc}", root)
                return
            # Committed → clear the whole thread (card + ack + nudge + the user's note/photo),
            # like the email-review room. The thread vanishing is the acknowledgment; a confirmation
            # reply would just keep it alive (and need redacting too). Needs the bot's redact/mod
            # power in the intake room to clear the user's own messages — else those linger.
            await redact_thread(room_id, root)
        elif action == "reject":
            await redact_thread(room_id, root)
        elif action == "edit":
            field, value = payload
            proposal = proposals.apply_edit(proposal, field, value)
            store.put(root, proposal)  # keep it pending (edited) for the next reply
            await say(room_id, "✏️ Updated:\n\n" + proposals.render(proposal), root)
        else:
            # Not yes/no/edit-grammar → treat it as a natural-language revision instruction and
            # re-run it through local Qwen (no Claude, no scrub). The human still approves the
            # revised card, so the draft→approve gate holds.
            try:
                revised = await asyncio.to_thread(parse.revise, proposal, text, roster=roster)
            except Exception as exc:
                store.put(root, proposal)
                await say(room_id, f"⚠️ couldn't apply that change ({str(exc)[:200]}).\n\nReply **yes** "
                                   "to commit, **no** to discard, **edit field=value**, or rephrase.", root)
                return
            if proposals.same_fields(proposal, revised):
                store.put(root, proposal)
                await say(room_id, "I didn't catch a change there. Reply **yes** to commit, **no** "
                                   "to discard, **edit field=value**, or tell me what to change.", root)
                return
            store.put(root, revised)
            await say(room_id, "✏️ Updated:\n\n" + proposals.render(revised), root)

    async def handle_disambiguation(room_id, root, text, proposal):
        cands = proposal.get("_candidates") or []
        action, payload = proposals.interpret_disambiguation(text, len(cands))
        if action == "pick":
            updated = proposals.attach_to_candidate(proposal, cands[payload])
            store.put(root, updated)
            await say(room_id, "✏️ Will log against the existing investor:\n\n"
                               + proposals.render(updated), root)
        elif action == "new":
            updated = proposals.promote_to_new(proposal)
            store.put(root, updated)
            await say(room_id, "➕ OK — adding as a new investor:\n\n"
                               + proposals.render(updated), root)
        elif action == "reject":
            await redact_thread(room_id, root)  # discard → clear the thread, like an approve
        else:  # unrecognized — re-show the shortlist
            store.put(root, proposal)
            await say(room_id, "I didn't catch that.\n\n" + proposals.render_disambiguation(proposal), root)

    async def redact_card(room_id, event_id):
        """Redact one event in `room_id` (best-effort). Redacting our OWN message needs no special
        power; redacting someone else's message (a human reply, or the user's original card photo /
        intake note) needs the bot to hold a redact/mod power level in that room."""
        try:
            await client.room_redact(room_id, event_id, reason="proposal resolved")
        except Exception as exc:
            print(f"matrix-intake: could not redact {event_id}: {exc}", flush=True)

    async def redact_thread(room_id, root):
        """Clear a resolved thread in `room_id`: redact the root AND every message that hangs off it
        — the m.thread children (cards/acks/human replies) AND the main-timeline **nudge** (a plain
        m.in_reply_to reply, not a thread child), so the thread drops out of both the threads view
        and the timeline. For email-review the root is the bot's card; for intake it's the USER'S
        own note/photo, so clearing it (and the human reply) needs the bot's redact/mod power in that
        room — without it those just no-op and linger. Replies are found by scanning recent history
        from the current sync token (the triggering reply is already synced, so a backward scan
        includes it)."""
        await redact_card(room_id, root)
        token = getattr(client, "next_batch", None)
        if not token:
            return
        try:
            scanned = 0
            for _ in range(MAX_THREAD_SCAN_PAGES):
                resp = await client.room_messages(room_id, start=token,
                                                  direction=MessageDirection.back, limit=100)
                chunk = getattr(resp, "chunk", None)
                if not chunk:
                    break
                for ev in chunk:
                    rel = ((getattr(ev, "source", None) or {}).get("content", {}) or {}).get("m.relates_to") or {}
                    in_reply = (rel.get("m.in_reply_to") or {}).get("event_id")
                    # A thread child carries rel_type=m.thread + event_id==root (the cards/acks +
                    # the human's yes/no replies — any sender). The un-threaded nudge is the BOT's
                    # own plain reply to root (only m.in_reply_to==root, no rel_type); gate that
                    # clause to our sender so we don't also redact an unrelated human plain-reply
                    # to the same root (root itself is already redacted above).
                    is_thread_child = rel.get("rel_type") == "m.thread" and rel.get("event_id") == root
                    is_own_nudge = in_reply == root and getattr(ev, "sender", None) == mx["user_id"]
                    if is_thread_child or is_own_nudge:
                        await redact_card(room_id, ev.event_id)
                token = getattr(resp, "end", None)
                scanned += len(chunk)
                if not token or scanned > 1000:
                    break
        except Exception as exc:
            print(f"matrix-intake: thread reply cleanup failed for {root}: {exc}", flush=True)

    async def handle_email_reply(room_id, root, text):
        """An in-thread reply to a CRM-drafted email-proposal card: yes commits, no dismisses, and
        anything else is a natural-language revision of the note (re-drafted by local Qwen; the
        human still approves the revised note, so the draft→approve gate holds). On a conclusive
        decision the card is redacted so the room clears down to only what still needs handling."""
        item = email_threads.get(root)
        if item is None:
            return  # a threaded reply we don't own (or already resolved)
        decision = email_proposals.interpret(text)
        if decision == "approve":
            # Claim before the await (double-approve guard, like the intake commit path).
            email_threads.pop(root, None)
            try:
                await asyncio.to_thread(crm_client.decide_email_proposal, item["id"], "approve", item.get("note"))
            except Exception as exc:
                email_threads[root] = item  # restore for retry
                await say(room_id, email_proposals.frame(f"⚠️ couldn't add it ({str(exc)[:200]}). Reply **yes** to retry, **no** to dismiss."), root)
                return
            # Success → clear the whole thread (card + replies). No confirmation: the thread
            # vanishing is the acknowledgment, and a confirmation reply would keep it alive.
            await redact_thread(review_room, root)
        elif decision == "reject":
            email_threads.pop(root, None)
            try:
                await asyncio.to_thread(crm_client.decide_email_proposal, item["id"], "dismiss")
            except Exception as exc:
                email_threads[root] = item
                await say(room_id, email_proposals.frame(f"⚠️ couldn't dismiss it ({str(exc)[:200]}). Try again."), root)
                return
            await redact_thread(review_room, root)
        else:
            try:
                new_note = await asyncio.to_thread(email_proposals.revise_note, item.get("note") or "", text)
            except Exception as exc:
                await say(room_id, email_proposals.frame(f"⚠️ couldn't revise that ({str(exc)[:200]}). Reply **yes** to add as-is, "
                                   "**no** to dismiss, or rephrase."), root)
                return
            if not new_note:
                await say(room_id, email_proposals.frame("I didn't catch a change. Reply **yes** to add the note as-is, **no** to "
                                   "dismiss, or tell me how to change it."), root)
                return
            item["note"] = new_note
            email_threads[root] = item
            await say(room_id, email_proposals.frame(f"✏️ Updated draft note:\n\n{new_note}\n\nReply **yes** to add it, **no** to "
                               "dismiss, or refine again."), root)

    async def poll_email_proposals():
        """Poll the CRM for email-activity proposals: post a review card for each new one, rebuild
        the reply-routing map from already-posted threads (so replies still route after a restart),
        and announce+close any decided on the web. One failing cycle logs and retries next tick."""
        while True:
            try:
                lists = await asyncio.to_thread(crm_client.list_email_proposals)
                for it in lists["open"]:  # rebuild routing for threads posted before (e.g. a restart)
                    ev = it.get("event_id")
                    if ev and ev not in email_threads:
                        email_threads[ev] = {"id": it["id"], "investor_name": it.get("investor_name"),
                                             "note": it.get("proposed_note") or ""}
                for it in lists["to_post"]:
                    try:
                        resp = await client.room_send(
                            review_room, "m.room.message",
                            matrix_io.thread_content(email_proposals.render_card(it), None))
                        ev = getattr(resp, "event_id", None)
                        if not ev:
                            print(f"matrix-intake: card send returned no event_id for {it['id']}", flush=True)
                            continue
                        await asyncio.to_thread(crm_client.mark_email_proposal_posted, it["id"], ev)
                        email_threads[ev] = {"id": it["id"], "investor_name": it.get("investor_name"),
                                             "note": it.get("proposed_note") or ""}
                    except Exception as exc:
                        print(f"matrix-intake: failed to post email proposal {it.get('id')}: {exc}", flush=True)
                for it in lists["to_close"]:  # decided on the web → clear the thread, then close
                    ev = it.get("event_id")
                    if not ev:
                        continue
                    try:
                        await redact_thread(review_room, ev)
                        await asyncio.to_thread(crm_client.mark_email_proposal_closed, it["id"])
                        email_threads.pop(ev, None)
                    except Exception as exc:
                        print(f"matrix-intake: failed to close email proposal {it.get('id')}: {exc}", flush=True)
            except Exception as exc:
                print(f"matrix-intake: email-proposal poll error: {exc}", flush=True)
            await asyncio.sleep(EMAIL_POLL_SEC)

    async def on_message(room: MatrixRoom, event: RoomMessageText):
        if event.sender == mx["user_id"]:
            return  # never react to our own messages (we post in-thread — this prevents loops)
        text = (event.body or "").strip()
        if not text:
            return
        root = matrix_io.thread_root_of(event)
        # Email-proposal review room: only a threaded reply to a card we posted is actionable.
        if review_room and room.room_id == review_room:
            if root and root in email_threads:
                await handle_email_reply(room.room_id, root, text)
            return
        # Dedicated Q&A room: every top-level message IS a question — no trigger needed. Threaded
        # messages (the answers we post, or follow-ups) aren't acted on in v1.
        if query_room and room.room_id == query_room:
            if not root:
                await handle_query(room.room_id, event.event_id, text)
            return
        if room.room_id != intake_room:
            return
        if root and store.has(root):
            await handle_reply(room.room_id, root, text)
        elif root:
            return  # threaded message not tied to a live proposal — ignore
        else:
            # A top-level message is either an NL question (explicitly addressed with '?'/'@bot')
            # or an intake note. The trigger is required, so plain notes still flow to intake.
            q = query.parse_trigger(text)
            if q is None:
                await handle_intake(room.room_id, event.event_id, text)
            elif not q:
                await say(room.room_id, query.HELP, event.event_id)
            else:
                await handle_query(room.room_id, event.event_id, q)

    async def on_image(room: MatrixRoom, event: RoomMessageImage):
        # Business-card capture is intake-only: ignore our own uploads, images in the Q&A /
        # email-review rooms, and an image dropped inside an existing thread (not a fresh card).
        if event.sender == mx["user_id"]:
            return
        if room.room_id != intake_room:
            return
        if matrix_io.thread_root_of(event):
            return
        await handle_card(room.room_id, event)

    # Prime the sync token past history, THEN register the callbacks — only react to messages
    # arriving after startup (no backlog replay). (matrix-bridge pattern.)
    print("matrix-intake: priming sync (skipping backlog)...", flush=True)
    await client.sync(timeout=30000, full_state=False)
    client.add_event_callback(on_message, RoomMessageText)
    client.add_event_callback(on_image, RoomMessageImage)
    who = await client.whoami()
    print(f"matrix-intake: listening as {who.user_id} in room {intake_room}", flush=True)
    tasks = [asyncio.create_task(client.sync_forever(timeout=30000))]
    if review_room:
        # "Invited" isn't "joined" — the bot must join before it can post cards (room_send to a
        # room we're only invited to fails M_FORBIDDEN). Idempotent if already a member.
        try:
            await client.join(review_room)
        except Exception as exc:
            print(f"matrix-intake: could not join review room {review_room}: {exc}", flush=True)
        tasks.append(asyncio.create_task(poll_email_proposals()))
        print(f"matrix-intake: reviewing email proposals in room {review_room} (every {EMAIL_POLL_SEC}s)", flush=True)
    if query_room:
        # Read-only Q&A room — just join and listen (no poll task; questions are interactive).
        # "Invited" isn't "joined": the bot must join before it can post answers (idempotent).
        try:
            await client.join(query_room)
        except Exception as exc:
            print(f"matrix-intake: could not join Q&A room {query_room}: {exc}", flush=True)
        print(f"matrix-intake: answering questions in room {query_room}", flush=True)
    try:
        await asyncio.gather(*tasks)
    finally:
        await client.close()


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass
