#!/usr/bin/env python3
"""Tests for reminders / follow-ups (W1).

Boots the REAL server against a temp DB and exercises the new endpoints end-to-end:
  - POST /api/reminders creates an open reminder tied to a grid investor (denormalized
    investor_name resolved from the grid), or a standalone task (no investor_id);
  - GET /api/reminders lists + filters by status (active/open/done/...), overdue, investor_id,
    assignee=me; every read is soft-delete filtered;
  - PATCH completes (stamps completed_at) / snoozes / edits a reminder; status is validated;
  - DELETE soft-deletes (gone from every list, never hard-deleted);
  - GET /api/fundraising/state injects a read-only reminder_status (overdue/due_soon/open/'')
    derived live from open reminders, and strips it on save (never persisted to the blob);
  - deleting an investor from the grid cancels its orphaned reminders (reconcile twin),
    while a standalone reminder is untouched;
  - the usual guards (missing title -> 400, bad status -> 400, unknown investor -> 404,
    unauthenticated -> 401).
Synthetic data only.

Run: cd backend && python3 test_reminders.py
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
from datetime import datetime, timedelta
from http.server import ThreadingHTTPServer

_DATA = tempfile.mkdtemp()
os.environ["CRM_DATA_DIR"] = _DATA
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import server  # noqa: E402

FAILS = []

TODAY = datetime.utcnow().date()
TOMORROW = (TODAY + timedelta(days=1)).isoformat()
YESTERDAY = (TODAY - timedelta(days=1)).isoformat()
FAR = (TODAY + timedelta(days=30)).isoformat()


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


class _Quiet(server.CRMHandler):
    def log_message(self, *a):
        pass


def _req(port, method, path, token=None, body=None):
    conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
    headers = {}
    if token:
        headers["Authorization"] = "Bearer " + token
    payload = None
    if body is not None:
        payload = json.dumps(body)
        headers["Content-Type"] = "application/json"
    conn.request(method, path, body=payload, headers=headers)
    resp = conn.getresponse()
    raw = resp.read().decode("utf-8", "replace")
    conn.close()
    data = None
    if raw:
        try:
            data = json.loads(raw)
        except ValueError:
            pass
    return resp.status, data


def _put_grid(port, token, rows):
    return _req(port, "PUT", "/api/fundraising/state", token,
                {"grid": {"columns": [], "rows": rows}, "views": []})


ROW_ACME = {"id": "rowAcme", "investor_name": "Acme Capital", "notes": "", "lead": "Grant",
            "contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]}
ROW_BETA = {"id": "rowBeta", "investor_name": "Beta Capital", "notes": "",
            "contacts": [{"name": "Pat Roe", "email": "pat@beta.com"}]}
ROW_GAMMA = {"id": "rowGamma", "investor_name": "Gamma Partners", "notes": "",
             "contacts": [{"name": "Sam Lee", "email": "sam@gamma.com"}]}


def _db():
    return sqlite3.connect(os.environ["CRM_DB_PATH"])


def seed():
    c = _db()
    c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
              "VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
    c.commit()
    c.close()


def _investor_id(source_row_id):
    c = _db()
    r = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id = ?", (source_row_id,)).fetchone()
    c.close()
    return r[0] if r else None


def _grid_reminder_status(port, token):
    st, d = _req(port, "GET", "/api/fundraising/state", token)
    rows = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
    return {r["id"]: r.get("reminder_status") for r in rows}


def main():
    server.init_db()
    seed()
    token = server.create_token("u1", "grant", "admin")

    httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
    port = httpd.server_address[1]
    threading.Thread(target=httpd.serve_forever, daemon=True).start()
    try:
        st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA, ROW_GAMMA])
        check(st == 200, f"seed grid via PUT /state (got {st})")
        acme_id = _investor_id("rowAcme")
        beta_id = _investor_id("rowBeta")
        gamma_id = _investor_id("rowGamma")
        check(bool(acme_id and beta_id and gamma_id), "investor ids resolved from the grid")

        # ── create: investor-linked + denormalized name resolved from the grid ──
        print("\n[create: investor-linked reminder resolves the denormalized name]")
        st, d = _req(port, "POST", "/api/reminders", token,
                     {"investor_id": acme_id, "title": "Send Fund III deck", "due_date": TOMORROW})
        rem = (d or {}).get("data") or {}
        acme_rem_id = rem.get("id")
        check(st == 201 and rem.get("status") == "open", f"create -> 201 open (got {st}, {d})")
        check(rem.get("investor_name") == "Acme Capital", f"name denormalized from grid (got {rem.get('investor_name')})")

        # overdue reminder on Beta; far-future + standalone for filter coverage
        st, d = _req(port, "POST", "/api/reminders", token,
                     {"investor_id": beta_id, "title": "Call Pat", "due_date": YESTERDAY})
        beta_rem_id = (d or {}).get("data", {}).get("id")
        check(st == 201, f"create overdue beta reminder (got {st})")
        st, d = _req(port, "POST", "/api/reminders", token,
                     {"investor_id": gamma_id, "title": "Quarterly touch", "due_date": FAR})
        gamma_rem_id = (d or {}).get("data", {}).get("id")
        st, d = _req(port, "POST", "/api/reminders", token, {"title": "Team: refresh pipeline view"})
        standalone_id = (d or {}).get("data", {}).get("id")
        check(st == 201 and (d or {}).get("data", {}).get("investor_id") in (None, ""),
              f"standalone reminder (no investor) created (got {st})")

        # ── list + filters ──
        print("\n[list + filters]")
        st, d = _req(port, "GET", "/api/reminders", token)
        items = (d or {}).get("data", [])
        check(st == 200 and len(items) == 4, f"list returns all 4 open (got {st}, {len(items)})")
        check(all("last_activity_at" in it for it in items), "each row carries last_activity_at")
        # 8h deep-link: each row injects source_row_id (joined via investor_id) — the grid row id
        # the mobile Reminders edit sheet uses for "Open investor in Grid"; null for a team task.
        by_id = {it.get("id"): it for it in items}
        check(by_id.get(acme_rem_id, {}).get("source_row_id") == "rowAcme",
              f"investor reminder injects source_row_id == 'rowAcme' (got {by_id.get(acme_rem_id, {}).get('source_row_id')!r})")
        check(by_id.get(standalone_id, {}).get("source_row_id") in (None, ""),
              f"team task has null source_row_id -> Open-in-Grid hidden (got {by_id.get(standalone_id, {}).get('source_row_id')!r})")
        # dated reminders sort before undated, soonest first -> YESTERDAY (beta) leads
        check(items and items[0].get("id") == beta_rem_id, f"overdue sorts first (got {items[0].get('id') if items else None})")

        st, d = _req(port, "GET", "/api/reminders?overdue=1", token)
        ids = [it["id"] for it in (d or {}).get("data", [])]
        check(ids == [beta_rem_id], f"overdue=1 -> only the past-due one (got {ids})")
        # overdue owns the status constraint: a conflicting status= must not silently zero out
        st, d = _req(port, "GET", "/api/reminders?overdue=1&status=done", token)
        ids = [it["id"] for it in (d or {}).get("data", [])]
        check(ids == [beta_rem_id], f"overdue wins over a conflicting status= (got {ids})")

        st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
        ids = [it["id"] for it in (d or {}).get("data", [])]
        check(ids == [acme_rem_id], f"investor_id filter (got {ids})")

        st, d = _req(port, "GET", "/api/reminders?assignee=me", token)
        check(len(d.get("data", [])) == 0, "assignee=me -> none (reminders created unassigned)")

        # ── grid injection: reminder_status derived live, never persisted ──
        print("\n[grid injection: read-only reminder_status]")
        s = _grid_reminder_status(port, token)
        check(s.get("rowAcme") == "due_soon", f"due-tomorrow -> due_soon (got {s.get('rowAcme')})")
        check(s.get("rowBeta") == "overdue", f"past-due -> overdue (got {s.get('rowBeta')})")
        check(s.get("rowGamma") == "open", f"far-future -> open (got {s.get('rowGamma')})")
        # echo the injected value back on save; it must NOT persist into the blob
        st, d = _req(port, "GET", "/api/fundraising/state", token)
        echoed = (d or {}).get("data", {}).get("grid", {}).get("rows", [])
        st, _ = _put_grid(port, token, echoed)
        c = _db()
        blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0])
        c.close()
        acme_stored = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {})
        check("reminder_status" not in acme_stored, "reminder_status not persisted into the grid blob")

        # ── complete / reopen stamps completed_at ──
        print("\n[complete + reopen]")
        st, d = _req(port, "PATCH", f"/api/reminders/{acme_rem_id}", token, {"status": "done"})
        check(st == 200 and (d or {}).get("data", {}).get("status") == "done"
              and (d or {}).get("data", {}).get("completed_at"), f"done stamps completed_at (got {d})")
        st, d = _req(port, "GET", "/api/reminders?status=open", token)
        check(acme_rem_id not in [it["id"] for it in (d or {}).get("data", [])], "done reminder drops from status=open")
        st, d = _req(port, "GET", "/api/reminders?status=done", token)
        check(acme_rem_id in [it["id"] for it in (d or {}).get("data", [])], "done reminder shows under status=done")
        check(_grid_reminder_status(port, token).get("rowAcme") in (None, ""), "completed reminder clears the grid chip")
        st, d = _req(port, "PATCH", f"/api/reminders/{acme_rem_id}", token, {"status": "open"})
        check((d or {}).get("data", {}).get("completed_at") in (None, ""), "reopen clears completed_at")

        # ── snooze: out of 'open', still in 'active' ──
        print("\n[snooze]")
        st, d = _req(port, "PATCH", f"/api/reminders/{beta_rem_id}", token,
                     {"status": "snoozed", "snoozed_until": FAR})
        check(st == 200 and (d or {}).get("data", {}).get("status") == "snoozed", f"snooze (got {st})")
        st, d = _req(port, "GET", "/api/reminders?status=open", token)
        check(beta_rem_id not in [it["id"] for it in (d or {}).get("data", [])], "snoozed drops from status=open")
        st, d = _req(port, "GET", "/api/reminders?status=active", token)
        check(beta_rem_id in [it["id"] for it in (d or {}).get("data", [])], "snoozed stays in status=active")

        # ── edit title + due date ──
        print("\n[edit]")
        st, d = _req(port, "PATCH", f"/api/reminders/{gamma_rem_id}", token,
                     {"title": "Quarterly touch — Q3", "due_date": TOMORROW})
        check((d or {}).get("data", {}).get("title") == "Quarterly touch — Q3"
              and (d or {}).get("data", {}).get("due_date") == TOMORROW, f"title+due edited (got {d})")

        # ── soft-delete: gone from every list, tombstoned not hard-deleted ──
        print("\n[soft-delete]")
        st, d = _req(port, "DELETE", f"/api/reminders/{standalone_id}", token)
        check(st == 200 and (d or {}).get("data", {}).get("deleted") is True, f"delete -> 200 (got {st})")
        st, d = _req(port, "GET", "/api/reminders", token)
        check(standalone_id not in [it["id"] for it in (d or {}).get("data", [])], "deleted reminder hidden from list")
        c = _db()
        gone = c.execute("SELECT deleted_at FROM reminders WHERE id = ?", (standalone_id,)).fetchone()[0]
        c.close()
        check(gone is not None, "deleted reminder tombstoned (deleted_at set), not hard-deleted")

        # ── orphan reconcile: drop the investor from the grid -> its reminders cancelled ──
        print("\n[orphan reconcile: deleting the grid investor cancels its reminders]")
        # create a fresh standalone task to confirm it is NOT cancelled by the reconciler
        st, d = _req(port, "POST", "/api/reminders", token, {"title": "Standalone keeps living"})
        keep_id = (d or {}).get("data", {}).get("id")
        st, _ = _put_grid(port, token, [ROW_ACME, ROW_BETA])  # drop rowGamma
        c = _db()
        gamma_status = c.execute("SELECT status FROM reminders WHERE id = ?", (gamma_rem_id,)).fetchone()[0]
        keep_status = c.execute("SELECT status FROM reminders WHERE id = ?", (keep_id,)).fetchone()[0]
        gamma_name = c.execute("SELECT investor_name FROM reminders WHERE id = ?", (gamma_rem_id,)).fetchone()[0]
        c.close()
        check(gamma_status == "cancelled", f"orphaned investor's reminder cancelled (got {gamma_status})")
        check(gamma_name == "Gamma Partners", "cancelled reminder keeps denormalized investor_name for history")
        check(keep_status == "open", f"standalone reminder untouched by reconcile (got {keep_status})")

        # ── recency rollup: a tombstoned email sighting must not inflate last_activity_at ──
        print("\n[recency: soft-deleted email sighting excluded from last_activity_at]")
        c = _db()
        c.execute("INSERT INTO emails (id, rfc_message_id, from_email, sent_at, is_matched, match_status) "
                  "VALUES ('em1','<em1@x>','lp@acme.com','2026-06-10T00:00:00Z',1,'matched')")
        c.execute("INSERT INTO email_investor_links (id, email_id, fundraising_investor_id, match_kind, match_confidence, matched_address) "
                  "VALUES ('eil1','em1',?, 'exact_email',1.0,'lp@acme.com')", (acme_id,))
        c.execute("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, gmail_thread_id, deleted_at) "
                  "VALUES ('eam1','em1','acct1','g1','t1','2026-06-11T00:00:00Z')")  # tombstoned sighting only
        c.commit(); c.close()
        st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
        items = (d or {}).get("data", [])
        la = items[0].get("last_activity_at") if items else "MISSING"
        check(bool(items) and la is None, f"tombstoned-only email -> no last_activity (got {la})")
        c = _db()
        c.execute("INSERT INTO email_account_messages (id, email_id, account_id, gmail_message_id, gmail_thread_id, deleted_at) "
                  "VALUES ('eam2','em1','acct2','g2','t2',NULL)")
        c.commit(); c.close()
        st, d = _req(port, "GET", f"/api/reminders?investor_id={acme_id}", token)
        items = (d or {}).get("data", [])
        la = items[0].get("last_activity_at") if items else "MISSING"
        check(bool(items) and la == '2026-06-10T00:00:00Z', f"live sighting -> last_activity set (got {la})")

        # ── guards ──
        print("\n[guards]")
        st, _ = _req(port, "POST", "/api/reminders", token, {"investor_id": acme_id})
        check(st == 400, f"missing title -> 400 (got {st})")
        st, _ = _req(port, "POST", "/api/reminders", token, {"title": "x", "investor_id": "nope"})
        check(st == 404, f"unknown investor_id -> 404 (got {st})")
        st, _ = _req(port, "PATCH", f"/api/reminders/{gamma_rem_id}", token, {"status": "bogus"})
        check(st == 400, f"invalid status -> 400 (got {st})")
        st, _ = _req(port, "DELETE", "/api/reminders/doesnotexist", token)
        check(st == 404, f"delete unknown -> 404 (got {st})")
        st, _ = _req(port, "GET", "/api/reminders", None)
        check(st == 401, f"unauthenticated list -> 401 (got {st})")
    finally:
        httpd.shutdown()

    print("\n" + ("ALL PASS" if not FAILS else f"{len(FAILS)} FAILURE(S):"))
    for f in FAILS:
        print("  - " + f)
    sys.exit(1 if FAILS else 0)


if __name__ == "__main__":
    main()
