#!/usr/bin/env python3
"""Regression test for the outreach NER-backstop wiring (v0.1.0:74).

The outreach draft path scrubs free-prose LP context (CRM notes + email bodies) before
it reaches Claude. The dictionary+regex floor only tokenizes KNOWN CRM entities, so an
UNKNOWN person/firm mentioned in an email body would otherwise reach Claude in the clear.
The v74 fix wired the local-Qwen NER backstop into draft_outreach (outreach_agent.py:
`Boundary(..., ner_fn=_ner_local)`) and made it FAIL CLOSED when the local model is down.

This drives the real draft_outreach with Claude and the NER model stubbed (offline,
synthetic — guardrail #9) and proves:
  (1) an unknown name in an email body is tokenized AWAY from the Claude payload;
  (2) it is re-hydrated locally so the human still sees the real name;
  (3) the interaction_log captures no sensitive value;
  (4) when the local NER model raises (unreachable), the path returns scrub_unavailable
      and Claude is never called.

Run: cd backend && python3 mcp/test_outreach_redaction.py
"""
import os
import sqlite3
import sys
import tempfile

_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)                       # backend/mcp
sys.path.insert(0, os.path.dirname(_HERE))      # backend (for the redaction package)

import outreach_agent as oa            # noqa: E402
import architect_grounding as G        # noqa: E402
import architect_agent as aa           # noqa: E402  (imports OK offline; client is lazy)

FAILS = []

UNKNOWN = "Penelope Ashworth-Vane"      # a person in NO CRM table -> only NER can catch her
INVESTOR = "Harbor & Vine"              # a known org (fundraising_investors) -> dictionary floor


def check(cond, msg):
    print(("  PASS " if cond else "  FAIL ") + msg)
    if not cond:
        FAILS.append(msg)


def make_db():
    path = os.path.join(tempfile.mkdtemp(), "crm.db")
    c = sqlite3.connect(path)
    c.row_factory = sqlite3.Row
    c.executescript("""
        CREATE TABLE fundraising_investors (id TEXT PRIMARY KEY, investor_name TEXT, notes TEXT);
        CREATE TABLE emails (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, snippet TEXT, sent_at TEXT,
            from_email TEXT, to_emails_json TEXT, thread_id TEXT, is_matched INT);
        CREATE TABLE email_investor_links (id TEXT, email_id TEXT, fundraising_investor_id TEXT);
        CREATE TABLE interaction_log (id TEXT PRIMARY KEY, ts TEXT, actor_type TEXT, actor_id TEXT, action TEXT,
            target_type TEXT, target_id TEXT, payload TEXT, source TEXT, created_at TEXT);
    """)
    c.execute("INSERT INTO fundraising_investors VALUES ('inv1',?,?)",
              (INVESTOR, "Warm on Fund III; weighing lock-up terms."))
    # The active-thread email body names an UNKNOWN person in free prose.
    c.execute("INSERT INTO emails (id,subject,body_text,sent_at,thread_id,is_matched) VALUES "
              "('e1','Re: Fund III',?,?,'t1',1)",
              (f"Thanks for the call. My partner {UNKNOWN} still has a lock-up objection.", "2026-06-02T10:00:00"))
    c.execute("INSERT INTO email_investor_links (id,email_id,fundraising_investor_id) VALUES ('l1','e1','inv1')")
    c.commit()
    return path, c


def main():
    db_path, conn = make_db()

    # Stub the thesis fetch (avoid the thesis DB dependency) and Claude. The NER stub stands
    # in for the local-Qwen model; _draft_with_claude echoes the de-identified text back so
    # re-hydration is exercised and we can inspect exactly what would have reached Claude.
    aa.at.get_thesis = lambda *a, **k: {}
    captured = {}

    def fake_claude(aa_mod, thesis, type_desc, deident_target, deident_voice, guidance):
        captured["target"] = deident_target
        return deident_target   # passthrough -> rehydrate must restore the real name

    oa._draft_with_claude = fake_claude
    G._ner_local = lambda text: [(UNKNOWN, "PERSON")]   # local model UP, finds the unknown name

    # ── A) unknown name is tokenized away from Claude, restored locally ──
    print("\n[A — NER backstop tokenizes an unknown name in outreach]")
    res = oa.draft_outreach(conn, "inv1", "follow_up", "", db_path, sender_email=None)
    check(res.get("status") == "ok", f"draft ok (status={res.get('status')})")
    sent = captured.get("target", "")
    check(UNKNOWN not in sent, "unknown name absent from the Claude payload (NER tokenized it)")
    check(INVESTOR not in sent, "known investor org absent from the Claude payload (dictionary floor)")
    check("lock-up" in sent, "objection substance survives to Claude")
    check(UNKNOWN in res.get("draft", ""), "unknown name re-hydrated locally for the human")

    blob = " ".join(r[0] for r in conn.execute("SELECT payload FROM interaction_log WHERE payload IS NOT NULL"))
    check(UNKNOWN not in blob and INVESTOR not in blob, "interaction_log carries NO sensitive value")

    # ── B) FAIL CLOSED: local NER model unreachable -> no Claude call ──
    print("\n[B — fail closed: local NER model down]")
    called = {"claude": False}

    def boom(text):
        raise RuntimeError("Spark Control unreachable")

    G._ner_local = boom
    oa._draft_with_claude = lambda *a, **k: called.__setitem__("claude", True) or a[3]
    res2 = oa.draft_outreach(conn, "inv1", "follow_up", "", db_path, sender_email=None)
    check(res2.get("status") == "scrub_unavailable", f"status scrub_unavailable (got {res2.get('status')})")
    check(bool(res2.get("reason")), "scrub_unavailable carries the propagated NER failure reason (non-vacuous)")
    check(called["claude"] is False, "Claude was NOT called when the NER model is down (fail closed)")
    check("draft" not in res2, "no draft returned when scrub fails closed")

    conn.close()
    print()
    if FAILS:
        print(f"FAILED ({len(FAILS)}):")
        for f in FAILS:
            print(f"  - {f}")
        sys.exit(1)
    print("ALL PASS (outreach NER-backstop wiring + fail-closed)")


if __name__ == "__main__":
    main()
