#!/usr/bin/env python3
"""Test for the admin soft-deleted purge (v0.1.0:104).

The purge is a deliberate, admin-only, type-to-confirm exception to never-hard-delete, for
clearing dummy/test data. It must be SAFE: only ever touch a soft-deleted row, and never
remove or mutate LIVE data via a cascade/SET-NULL. This boots the real server, seeds live +
soft-deleted graphs, and drives /api/admin/soft-deleted[/purge] over HTTP. Synthetic only.

Run: cd backend && python3 test_purge_soft_deleted.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 = []
DEL = "2026-06-01T00:00:00"


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()
    body = resp.read().decode("utf-8", "replace")
    conn.close()
    try:
        return resp.status, (json.loads(body) if body else None)
    except ValueError:
        return resp.status, None


def _get(port, path, token):
    conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
    conn.request("GET", path, headers={"Authorization": "Bearer " + token})
    resp = conn.getresponse()
    body = resp.read().decode("utf-8", "replace")
    conn.close()
    try:
        return resp.status, (json.loads(body) if body else None)
    except ValueError:
        return resp.status, None


def exists(table, rid):
    c = sqlite3.connect(os.environ["CRM_DB_PATH"])
    n = c.execute(f"SELECT COUNT(*) FROM {table} WHERE id = ?", (rid,)).fetchone()[0]
    c.close()
    return n > 0


def seed():
    c = sqlite3.connect(os.environ["CRM_DB_PATH"])
    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)")
    # Soft-deleted contact with ONLY soft-deleted children -> purgeable; cascade should remove them.
    c.execute("INSERT INTO contacts (id,first_name,last_name,deleted_at) VALUES ('cClean','Dummy','Clean',?)", (DEL,))
    c.execute("INSERT INTO opportunities (id,name,contact_id,owner_id,deleted_at) VALUES ('opC','Opp','cClean','u1',?)", (DEL,))
    c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject,deleted_at) VALUES ('cmC','cClean','2026-05-01','u1','note',?)", (DEL,))
    # A reminder pointing at the purge target (reminders.contact_id is a bare logical FK, no ON DELETE):
    # the purge must NULL it, not leave it dangling and not delete the reminder.
    c.execute("INSERT INTO reminders (id,contact_id,investor_id,title) VALUES ('remC','cClean','inv-x','Follow up dummy')")
    # Soft-deleted contact WITH a live child -> must refuse (cascade would kill live data).
    c.execute("INSERT INTO contacts (id,first_name,last_name,deleted_at) VALUES ('cLiveKid','Has','Livekid',?)", (DEL,))
    c.execute("INSERT INTO communications (id,contact_id,communication_date,created_by,subject) VALUES ('cmLive','cLiveKid','2026-05-02','u1','live note')")
    # A live contact -> must refuse (not soft-deleted).
    c.execute("INSERT INTO contacts (id,first_name,last_name) VALUES ('cLive','Real','Person')")
    # Soft-deleted org with no live refs -> purgeable.
    c.execute("INSERT INTO organizations (id,name,deleted_at) VALUES ('orgClean','Dead Org',?)", (DEL,))
    # Soft-deleted org referenced by a LIVE contact -> must refuse (SET NULL would mutate live data).
    c.execute("INSERT INTO organizations (id,name,deleted_at) VALUES ('orgRef','Ref Org',?)", (DEL,))
    c.execute("INSERT INTO contacts (id,first_name,last_name,organization_id) VALUES ('cRef','Org','Member','orgRef')")
    c.commit()
    c.close()


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:
        print("\n[list soft-deleted]")
        st, body = _get(port, "/api/admin/soft-deleted", token)
        groups = (body or {}).get("groups", {})
        cids = {x["id"] for x in groups.get("contacts", [])}
        oids = {x["id"] for x in groups.get("organizations", [])}
        check(st == 200, f"GET soft-deleted -> 200 (got {st})")
        check({"cClean", "cLiveKid"} <= cids and "cLive" not in cids, f"lists soft-deleted contacts only (got {cids})")
        check({"orgClean", "orgRef"} <= oids, f"lists soft-deleted orgs (got {oids})")
        check("opC" in {x["id"] for x in groups.get("opportunities", [])}, "lists the soft-deleted opportunity")

        print("\n[purge guards]")
        st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "cLive"})
        check(st == 400, f"purge a LIVE contact -> 400 (got {st})")
        check(exists("contacts", "cLive"), "live contact still present after refused purge")
        st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "cLiveKid"})
        check(st == 409, f"purge contact with a LIVE child -> 409 (got {st})")
        check(exists("contacts", "cLiveKid") and exists("communications", "cmLive"), "contact + its live child preserved")
        st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "organizations", "id": "orgRef"})
        check(st == 409, f"purge org referenced by a LIVE contact -> 409 (got {st})")
        check(exists("organizations", "orgRef") and exists("contacts", "cRef"), "org + its live referencing contact preserved")
        st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "bogus", "id": "x"})
        check(st == 400, f"unknown table -> 400 (got {st})")
        st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "nope"})
        check(st == 404, f"missing id -> 404 (got {st})")

        print("\n[purge happy path + cascade]")
        st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "contacts", "id": "cClean"})
        check(st == 200, f"purge a clean soft-deleted contact -> 200 (got {st})")
        check(not exists("contacts", "cClean"), "purged contact is gone")
        check(not exists("opportunities", "opC") and not exists("communications", "cmC"),
              "its soft-deleted children were cascade-removed")
        _rc = sqlite3.connect(os.environ["CRM_DB_PATH"])
        _rem = _rc.execute("SELECT contact_id FROM reminders WHERE id = 'remC'").fetchone()
        _rc.close()
        check(_rem is not None and _rem[0] is None,
              "a reminder on the purged contact is KEPT but its contact_id is NULL'd (no dangling ref)")
        st, _ = _post(port, "/api/admin/soft-deleted/purge", token, {"table": "organizations", "id": "orgClean"})
        check(st == 200, f"purge a clean soft-deleted org -> 200 (got {st})")
        check(not exists("organizations", "orgClean"), "purged org is gone")
    finally:
        httpd.shutdown()

    print()
    if FAILS:
        print(f"{len(FAILS)} FAILED")
        sys.exit(1)
    print("ALL PASS (soft-deleted purge)")


if __name__ == "__main__":
    main()
