#!/usr/bin/env python3
"""Redact personal information from a text or CSV file before it goes into an AI chat.

    python3 redact.py sample-input.csv --dry-run          # show what WOULD change; writes nothing
    python3 redact.py sample-input.csv                    # writes sample-input.redacted.csv + sample-input.map.json
    python3 redact.py notes.txt --names names.txt         # also replace the names listed in names.txt with initials
    python3 redact.py --self-test                         # checks every pattern on canned text; no files touched

Deterministic: no AI involved, the same input always produces the same output. Standard library only.
What it replaces (edit the PATTERNS table to fit your data):
  emails -> [EMAIL-1], [EMAIL-2] ...         phone numbers -> [PHONE-n]
  SSN-shaped numbers (123-45-6789) -> [SSN]  license numbers (LIC-, C10-, CDPH- ... prefixes) -> prefix + last 4
  customer / patient IDs (CUST-1234, PT-88) -> CUST-001, CUST-002 ... in order of first appearance
  street addresses -> [ADDRESS-n]           names from --names -> initials (Dana Reyes -> D.R.)
Metrc tags (24 characters) are KEPT by default because reconciliation tasks need them; pass --tags to keep only the last 4.
The .map.json file lets you put the real values back afterwards. Keep it out of the chat window.
CC0 1.0. From Distru's No Bullshit AI Course, lesson 01-03. Tested September 2026 on Python 3.9+.
"""
import argparse
import json
import re
import sys
from pathlib import Path

# ---- patterns, applied in this order. Each: (label, compiled regex, how to replace) ----
# how: "counter" -> [LABEL-n] per distinct value; "last4" -> keep prefix and last 4; "fixed" -> [LABEL]
PATTERNS = [
    ("EMAIL", re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"), "counter"),
    ("SSN", re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "fixed"),
    ("PHONE", re.compile(r"(?<!\d)(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}(?!\d)"), "counter"),
    # state license numbers: common prefixes, then letters/digits. Add your state's prefix here.
    ("LICENSE", re.compile(r"\b(?:LIC|C\d{1,2}|CDPH|CCL|OCM|MED|AU)[- ]?\d?[- ]?[A-Z0-9]{4,}\b"), "last4"),
    # customer / patient / member ids
    ("CUST", re.compile(r"\b(?:CUST|CUSTOMER|PT|PATIENT|MEM|MEMBER)[- ]?\d{2,}\b", re.I), "counter"),
    ("ADDRESS", re.compile(r"\b\d{1,6}\s+(?:[A-Z][a-z]+\s){1,3}(?:St|Street|Ave|Avenue|Rd|Road|Blvd|Boulevard|Dr|Drive|Ln|Lane|Way|Ct|Court|Hwy|Highway)\b\.?"), "counter"),
]
TAG_PATTERN = re.compile(r"\b1A4[0-9A-F]{21}\b")   # Metrc UID; only touched with --tags
# --------------------------------------------------------------------------------------------


class Redactor:
    def __init__(self, names=None, tags=False):
        self.names = names or []
        self.tags = tags
        self.map = {}        # placeholder -> original
        self.counts = {}     # label -> occurrences
        self._seen = {}      # (label, original) -> placeholder

    def _placeholder(self, label, original):
        key = (label, original)
        if key not in self._seen:
            n = sum(1 for k in self._seen if k[0] == label) + 1
            self._seen[key] = f"[{label}-{n}]" if label != "CUST" else f"CUST-{n:03d}"
            self.map[self._seen[key]] = original
        return self._seen[key]

    def redact(self, text):
        # Metrc tags contain 10+ digit runs that look like phone numbers; shield them first.
        shielded = {}
        def shield(m):
            key = f"\x00TAG{len(shielded)}\x00"
            shielded[key] = m.group(0)
            return key
        text = TAG_PATTERN.sub(shield, text)
        for label, rx, how in PATTERNS:
            def sub(m, label=label, how=how):
                self.counts[label] = self.counts.get(label, 0) + 1
                v = m.group(0)
                if how == "fixed":
                    return f"[{label}]"
                if how == "last4":
                    prefix = re.match(r"[A-Za-z]+\d{0,2}", v).group(0)
                    self.map.setdefault(f"{prefix}-…{v[-4:]}", v)
                    return f"{prefix}-…{v[-4:]}"
                return self._placeholder(label, v)
            text = rx.sub(sub, text)
        for key, tag in shielded.items():
            text = text.replace(key, tag)
        if self.tags:
            def tag(m):
                self.counts["TAG"] = self.counts.get("TAG", 0) + 1
                self.map.setdefault(f"…{m.group(0)[-4:]}", m.group(0))
                return f"…{m.group(0)[-4:]}"
            text = TAG_PATTERN.sub(tag, text)
        for full in sorted(self.names, key=len, reverse=True):
            full = full.strip()
            if not full:
                continue
            initials = ".".join(p[0].upper() for p in full.split()) + "."
            rx = re.compile(r"\b" + re.escape(full) + r"\b", re.I)
            n_before = len(rx.findall(text))
            if n_before:
                self.counts["NAME"] = self.counts.get("NAME", 0) + n_before
                self.map.setdefault(initials, full)
                text = rx.sub(initials, text)
        return text


def self_test():
    r = Redactor(names=["Dana Reyes"], tags=True)
    src = ("Call Dana Reyes at (555) 010-2233 or dana.reyes@greenfern.example, "
           "license C10-0000123-LIC, member MEM-44821, SSN 123-45-6789, "
           "ship to 412 Harbor View Rd, tag 1A4060300003B65000000123, again dana reyes.")
    out = r.redact(src)
    checks = {
        "email": "[EMAIL-1]" in out and "greenfern" not in out,
        "phone": "[PHONE-1]" in out and "010-2233" not in out,
        "ssn": "[SSN]" in out and "123-45-6789" not in out,
        "license": "C10-…0123-LIC" in out and "0000123" not in out,
        "customer id": "CUST-001" in out and "44821" not in out,
        "address": "[ADDRESS-1]" in out and "Harbor View" not in out,
        "tag last4": "…0123" in out and "1A4060300003B65000000123" not in out,
        "name -> initials (both spellings)": out.count("D.R.") == 2 and "Reyes" not in out,
        "map restores": all(v in src for v in r.map.values()),
    }
    for k, ok in checks.items():
        print(("ok   " if ok else "FAIL ") + k)
    print("\n" + out)
    return all(checks.values())


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("file", nargs="?", help="text or CSV file to redact")
    ap.add_argument("--dry-run", action="store_true", help="print counts and a before/after sample; write nothing")
    ap.add_argument("--names", help="file with one full name per line; each becomes initials")
    ap.add_argument("--tags", action="store_true", help="also shorten 24-char Metrc tags to their last 4")
    ap.add_argument("--self-test", action="store_true", help="run the built-in checks and exit")
    a = ap.parse_args()

    if a.self_test:
        sys.exit(0 if self_test() else 1)
    if not a.file:
        ap.error("give a file, or --self-test")

    path = Path(a.file)
    names = Path(a.names).read_text(encoding="utf-8").splitlines() if a.names else []
    r = Redactor(names=names, tags=a.tags)
    src = path.read_text(encoding="utf-8")
    out = r.redact(src)

    total = sum(r.counts.values())
    print(f"{path.name}: {total} replacement(s)" + ("  (dry run, nothing written)" if a.dry_run else ""))
    for label, n in sorted(r.counts.items()):
        print(f"  {label:8} {n}")
    if a.dry_run:
        print("\nfirst 12 replacements:")
        for i, (ph, orig) in enumerate(r.map.items()):
            if i == 12:
                break
            print(f"  {orig!r:45} -> {ph}")
        return

    out_path = path.with_name(path.stem + ".redacted" + path.suffix)
    map_path = path.with_name(path.stem + ".map.json")
    out_path.write_text(out, encoding="utf-8")
    map_path.write_text(json.dumps(r.map, indent=2, ensure_ascii=False), encoding="utf-8")
    print(f"\nwrote {out_path.name}  (paste this one)")
    print(f"wrote {map_path.name}   (keep this one OUT of the chat)")


if __name__ == "__main__":
    main()
