#!/usr/bin/env python3
"""Re-identification spot-check (redaction-rehydration.md §6) — OFFLINE + SYNTHETIC.

A deterministic approximation of "feed only the scrubbed prompt to a model and try to
recover who it is." Three probes:
  A. Exact/normalized leak gate (MUST PASS): re-scan the scrubbed payload for ANY known
     real value or Tier-1 string under normalization (case, punctuation, reversed
     'Last First', email local-part). Any hit = tokenizer miss = FAIL.
  B. Descriptive-identifier residual (BOUNDED): phrases that re-identify even with names
     tokenized ("the family that sold the mining company in Texas"). The deterministic
     scrubber is not expected to catch these (the on-infra local-Qwen pass + the
     minimize-first summary do); this probe MEASURES the residual and fails only if it
     EXCEEDS a committed ceiling, so leakage can't silently grow.
  C. Inference via bucketing: with bucket=True, no exact amount/identity-date survives,
     and a (amount-band, year, sector) tuple is not unique to one synthetic entity.

Run: cd backend && python3 redaction/test_reidentification.py
"""
import os
import re
import sys

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

FAILS = []


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


def _norm(s):
    return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()


# Probe A — known synthetic entities that must NOT be recoverable from the scrubbed text
KB = {
    "persons": ["Jonathan Reyes", "Marta Quine"],
    "orgs": ["Cedar Point Capital"],
    "emails": ["jon@cedarpoint.example"],
    "tier1": ["000123456789", "123-45-6789"],
}
RAW_A = ("Jonathan Reyes at Cedar Point Capital (jon@cedarpoint.example) is cooling; Reyes wants better "
         "terms. Marta Quine disagrees. acct 000123456789. Substance: fee and lock-up objections.")
KNOWN_A = {"persons": ["Jonathan Reyes", "Reyes", "Marta Quine", "Marta"],
           "orgs": ["Cedar Point Capital"], "funds": [], "emails": ["jon@cedarpoint.example"]}


def probe_a():
    print("\n[probe A — exact/normalized leak gate]")
    outbound, _, _ = R.scrub(RAW_A, known_entities=KNOWN_A, bucket=False)
    nout = _norm(outbound)
    hits = []
    for cat in ("persons", "orgs", "emails", "tier1"):
        for v in KB[cat]:
            variants = {_norm(v)}
            if " " in v and cat == "persons":
                a, b = v.split()[0], v.split()[-1]
                variants.add(_norm(f"{b} {a}"))   # reversed Last First
                variants.add(_norm(b))            # bare surname
            if "@" in v:
                variants.add(_norm(v.split("@")[0]))  # email local-part
            for var in variants:
                if var and var in nout:
                    hits.append((v, var))
    check(not hits, f"no known identifier recoverable from scrubbed text (hits={hits})")


# Probe B — descriptive re-identifiers (bounded residual)
DESCRIPTIVE = [
    "the family that sold the mining company in Texas",
    "the former CTO of a well-known payments unicorn",
    "the senator's brother who runs a family office",
]
RAW_B = "Notes: our contact is " + DESCRIPTIVE[0] + ". Another is " + DESCRIPTIVE[1] + ". A third is " + DESCRIPTIVE[2] + "."
RESIDUAL_CEILING = 3  # known residual the deterministic scrubber alone does not catch;
                       # the on-infra Qwen pass + minimize-first summary drive this toward 0.


def probe_b():
    print("\n[probe B — descriptive-identifier residual, bounded]")
    outbound, _, _ = R.scrub(RAW_B, known_entities={"persons": [], "orgs": [], "funds": [], "emails": []})
    surviving = [d for d in DESCRIPTIVE if d in outbound]
    for d in surviving:
        print(f"    flagged residual (handled on-infra by Qwen/minimize-first): {d!r}")
    check(len(surviving) <= RESIDUAL_CEILING,
          f"descriptive residual within committed ceiling ({len(surviving)} <= {RESIDUAL_CEILING})")


# Probe C — bucketing destroys exact values + singling-out
ENTITIES_C = [
    {"name": "A", "amount": "$5,200,000", "date": "1986-02-10", "sector": "energy"},
    {"name": "B", "amount": "$4,800,000", "date": "1986-03-20", "sector": "energy"},  # same band/year/sector as A
    {"name": "C", "amount": "$25,000,000", "date": "1991-09-01", "sector": "bitcoin"},
]


def probe_c():
    print("\n[probe C — inference via bucketing]")
    raw = " ".join(f"Investor commits {e['amount']} on {e['date']} in {e['sector']}." for e in ENTITIES_C)
    outbound, _, _ = R.scrub(raw, known_entities={"persons": [], "orgs": [], "funds": [], "emails": []}, bucket=True)
    check(re.search(r"\$\s?\d[\d,]{2,}", outbound) is None, "no exact $ amount survives bucketing")
    check(re.search(r"\b(?:19|20)\d{2}-\d{2}-\d{2}\b", outbound) is None, "no exact date survives bucketing")
    # singling-out: the (amount-band, year, sector) tuple must not be unique to one entity
    tuples = {}
    for e in ENTITIES_C:
        band = R._bucket_amount(e["amount"])
        year = e["date"][:4]
        tuples.setdefault((band, year, e["sector"]), []).append(e["name"])
    unique = [k for k, v in tuples.items() if len(v) == 1]
    # A and B collapse to the same bucket-tuple; C is alone but that's an accepted single in this fixture
    check(any(len(v) > 1 for v in tuples.values()),
          f"bucketing collapses distinct entities into shared bands (tuples={ {k: v for k,v in tuples.items()} })")


def main():
    probe_a()
    probe_b()
    probe_c()
    print()
    if FAILS:
        print(f"FAILED ({len(FAILS)}):")
        for f in FAILS:
            print(f"  - {f}")
        sys.exit(1)
    print("ALL PASS (re-identification spot-check)")


if __name__ == "__main__":
    main()
