#!/usr/bin/env python3
"""Integration test for the version-safe single-row update endpoint (P3b).

POST /api/fundraising/update-row edits ONE investor row's name and/or contact pills by
reading the canonical grid blob fresh server-side and mutating only the target row — never
accepting a whole-grid payload (BRIEF §3a), so it can't clobber concurrent edits to other
rows. This boots the REAL server against a throwaway DB, seeds a two-row grid, then drives
the live HTTP endpoint to assert:
  * rename + pill add/edit persist into the blob and bump the version;
  * removing a pill drops it from the row + fundraising_contacts, but the classic contacts
    directory entry is NOT hard-deleted (soft-delete-only convention);
  * preserved pill fields (title/city/linkedin) survive an edit that only touches the name;
  * the OTHER grid row is untouched (no whole-grid clobber);
  * guards: missing row_id -> 400, unknown row_id -> 404, blank name -> 400,
    no-op body (neither field) -> 400.
Synthetic data only (guardrail #9).

Run:  cd backend && python3 test_fundraising_update_row.py
"""
import http.client
import json
import os
import sqlite3
import sys
import tempfile
import threading
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 = []


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 _post(port, path, token, payload):
    conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
    conn.request("POST", path, body=json.dumps(payload),
                 headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"})
    resp = conn.getresponse()
    raw = resp.read().decode("utf-8", "replace")
    conn.close()
    data = json.loads(raw) if raw else None
    return resp.status, data


GRID = {
    "columns": [
        {"id": "investor_name", "label": "Investor Name", "type": "text"},
        {"id": "contacts", "label": "Contacts", "type": "contacts"},
        {"id": "notes", "label": "Notes", "type": "text"},
    ],
    "rows": [
        {"id": "row-1", "investor_name": "Acme Capital", "notes": "",
         "contacts": [
             {"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner",
              "city": "Austin", "state": "TX", "country": "USA", "location_query": "Austin",
              "linkedin_url": "https://linkedin.com/in/janedoe"},
             {"name": "Bob Roe", "email": "bob@acme.com", "title": ""},
         ]},
        {"id": "row-2", "investor_name": "Beacon Fund", "notes": "untouched",
         "contacts": [{"name": "Carl Vane", "email": "carl@beacon.com"}]},
    ],
}


def seed():
    c = sqlite3.connect(os.environ["CRM_DB_PATH"])
    c.row_factory = sqlite3.Row
    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.execute("INSERT INTO fundraising_state (id, grid_json, views_json, version, updated_by) "
              "VALUES ('main', ?, '[]', 1, 'u1') "
              "ON CONFLICT(id) DO UPDATE SET grid_json=excluded.grid_json, views_json='[]', version=1",
              (json.dumps(GRID),))
    server.sync_fundraising_relational(c, server.sanitize_fundraising_grid(GRID), [], actor_user_id="u1")
    c.commit()
    c.close()


def blob_rows():
    c = sqlite3.connect(os.environ["CRM_DB_PATH"])
    c.row_factory = sqlite3.Row
    row = c.execute("SELECT grid_json, version FROM fundraising_state WHERE id='main'").fetchone()
    c.close()
    grid = json.loads(row["grid_json"])
    by_id = {r["id"]: r for r in grid["rows"]}
    return by_id, int(row["version"])


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:
        # ── rename + add a third contact, edit Bob's title, keep Jane unchanged ──
        print("\n[rename + pill add/edit]")
        new_contacts = [
            # Jane: only her name is "re-sent"; the client preserves her other fields by spread.
            {"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner",
             "city": "Austin", "state": "TX", "country": "USA", "location_query": "Austin",
             "linkedin_url": "https://linkedin.com/in/janedoe"},
            {"name": "Bob Roe", "email": "bob@acme.com", "title": "Principal"},   # edited title
            {"name": "Dana Fox", "email": "dana@acme.com", "title": "Analyst"},   # added
        ]
        st, data = _post(port, "/api/fundraising/update-row", token,
                         {"row_id": "row-1", "investor_name": "Acme Capital LLC", "contacts": new_contacts})
        check(st == 200, f"update-row -> 200 (got {st})")
        by_id, version = blob_rows()
        check(version == 2, f"version bumped 1 -> 2 (got {version})")
        r1 = by_id.get("row-1", {})
        check(r1.get("investor_name") == "Acme Capital LLC", f"name renamed in blob (got {r1.get('investor_name')!r})")
        names = [c.get("name") for c in r1.get("contacts", [])]
        check(names == ["Jane Doe", "Bob Roe", "Dana Fox"], f"three pills in order (got {names})")
        bob = next((c for c in r1["contacts"] if c["name"] == "Bob Roe"), {})
        check(bob.get("title") == "Principal", f"Bob's title edited (got {bob.get('title')!r})")
        jane = next((c for c in r1["contacts"] if c["name"] == "Jane Doe"), {})
        check(jane.get("linkedin_url") == "https://linkedin.com/in/janedoe" and jane.get("city") == "Austin",
              f"Jane's preserved fields survived (got {jane})")

        # ── the OTHER row is byte-for-byte untouched (no whole-grid clobber) ──
        print("\n[other row untouched]")
        r2 = by_id.get("row-2", {})
        check(r2.get("investor_name") == "Beacon Fund" and r2.get("notes") == "untouched"
              and [c.get("name") for c in r2.get("contacts", [])] == ["Carl Vane"],
              f"row-2 unchanged (got {r2})")

        # ── relational sync: classic contacts directory now has Dana; Dana also in fundraising_contacts ──
        print("\n[relational + classic-contacts sync]")
        c = sqlite3.connect(os.environ["CRM_DB_PATH"])
        c.row_factory = sqlite3.Row
        dana = c.execute("SELECT id, deleted_at FROM contacts WHERE lower(email)='dana@acme.com'").fetchone()
        check(bool(dana), "added contact Dana propagated to classic contacts directory")
        inv = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id='row-1'").fetchone()
        fc_names = {r["full_name"] for r in c.execute(
            "SELECT full_name FROM fundraising_contacts WHERE investor_id=?", (inv["id"],)).fetchall()}
        check(fc_names == {"Jane Doe", "Bob Roe", "Dana Fox"}, f"fundraising_contacts mirrors the three pills (got {fc_names})")
        c.close()

        # ── remove Bob: pill drops + fundraising_contacts drops, but classic contact NOT hard-deleted ──
        print("\n[remove pill is soft on the classic directory]")
        st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": [
            {"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner"},
            {"name": "Dana Fox", "email": "dana@acme.com", "title": "Analyst"},
        ]})
        check(st == 200, f"remove-pill update -> 200 (got {st})")
        by_id, version = blob_rows()
        names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
        check(names == ["Jane Doe", "Dana Fox"], f"Bob removed from the row (got {names})")
        check(version == 3, f"version bumped 2 -> 3 (got {version})")
        c = sqlite3.connect(os.environ["CRM_DB_PATH"])
        c.row_factory = sqlite3.Row
        inv = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id='row-1'").fetchone()
        fc_names = {r["full_name"] for r in c.execute(
            "SELECT full_name FROM fundraising_contacts WHERE investor_id=?", (inv["id"],)).fetchall()}
        check("Bob Roe" not in fc_names, f"Bob dropped from fundraising_contacts (got {fc_names})")
        bob_classic = c.execute("SELECT id FROM contacts WHERE lower(email)='bob@acme.com'").fetchone()
        check(bool(bob_classic), "removing a pill does NOT hard-delete the classic contacts row (soft-delete only)")
        c.close()

        # ── name-only update (no contacts key) leaves the pill list intact ──
        print("\n[name-only update preserves contacts]")
        st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "investor_name": "Acme Capital"})
        check(st == 200, f"name-only update -> 200 (got {st})")
        by_id, _ = blob_rows()
        names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
        check(by_id["row-1"].get("investor_name") == "Acme Capital", "name updated again")
        check(names == ["Jane Doe", "Dana Fox"], f"contacts untouched by a name-only edit (got {names})")

        # ── a name-only pill (no email) is KEPT; a fully-blank pill is dropped ──
        # (locks _sanitize_fundraising_contacts's emptiness rule = name OR email, not AND.)
        print("\n[name-only pill kept, blank pill dropped]")
        st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": [
            {"name": "Jane Doe", "email": "jane@acme.com"},
            {"name": "Erin Pope", "email": ""},          # name only -> kept
            {"name": "", "email": ""},                    # fully blank -> dropped
            {"name": "  ", "title": "Ghost"},             # whitespace-only name, no email -> dropped
        ]})
        check(st == 200, f"name-only-pill update -> 200 (got {st})")
        by_id, _ = blob_rows()
        names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
        check(names == ["Jane Doe", "Erin Pope"],
              f"name-only pill kept, blank/whitespace pills dropped (got {names})")

        # ── guards ──
        print("\n[validation guards]")
        st, _ = _post(port, "/api/fundraising/update-row", token, {"investor_name": "No Id"})
        check(st == 400, f"missing row_id -> 400 (got {st})")
        st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-nope", "investor_name": "Ghost"})
        check(st == 404, f"unknown row_id -> 404 (got {st})")
        st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "investor_name": "   "})
        check(st == 400, f"blank name -> 400 (got {st})")
        st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1"})
        check(st == 400, f"no-op body (neither name nor contacts) -> 400 (got {st})")
        st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": "nope"})
        check(st == 400, f"contacts wrong type -> 400 (got {st})")
    finally:
        httpd.shutdown()

    print()
    if FAILS:
        print(f"FAILED ({len(FAILS)}):")
        for f in FAILS:
            print(f"  - {f}")
        sys.exit(1)
    print("ALL PASS (fundraising update-row: version-safe single-row name/pill edit)")


if __name__ == "__main__":
    main()
