"""
Solution: finding and removing personal data. Lab 15's pii.py, trimmed: patterns confirmed by
checksums and rules, merging overlaps, redaction with pseudonyms, combining patterns with a
model's answer, and counting what's left by message.
"""
import ipaddress
import re

EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
CARD = re.compile(r"\b(?:\d[ -]?){12,18}\d\b")  # 13-19 digits, maybe grouped by spaces or dashes
SSN = re.compile(r"\b(\d{3})-(\d{2})-(\d{4})\b")
IBAN = re.compile(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,3})?\b")
IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
PHONE = re.compile(r"(?<![\w+])(?:\+\d{1,3}[ .-]?)?(?:\(\d{3}\)|\d{3})[ .-]?\d{3}[ .-]?\d{4}\b")


def luhn_ok(digits):
    """The Luhn check every payment card number passes. From the right, double every second
    digit (the 2nd, 4th, ... from the right); if that gives 10 or more, subtract 9. Add all the
    digits up: a card's total ends in 0. Anything that isn't at least 2 digits fails."""
    if not digits.isdigit() or len(digits) < 2:
        return False
    total = 0
    for i, d in enumerate(reversed(digits)):
        n = int(d)
        if i % 2 == 1:
            n = n * 2 - 9 if n > 4 else n * 2
        total += n
    return total % 10 == 0


def ssn_ok(area, group, serial):
    """US social security numbers are never issued with area 000, 666 or 900-999, group 00, or
    serial 0000."""
    return area not in ("000", "666") and not area.startswith("9") and group != "00" and serial != "0000"


def iban_ok(iban):
    """Given: move the first 4 characters to the end, letters to numbers (A=10 ... Z=35), mod 97 is 1."""
    s = iban.replace(" ", "").upper()
    if not re.fullmatch(r"[A-Z]{2}\d{2}[A-Z0-9]{11,30}", s):
        return False
    return int("".join(str(int(c, 36)) for c in s[4:] + s[:4])) % 97 == 1


def ip_ok(s):
    try:
        ipaddress.IPv4Address(s)
        return True
    except ValueError:
        return False


def merge(spans):
    """Resolve overlaps: take spans longest first (ties: earlier start first); drop a span that
    overlaps one already kept. Return the kept spans sorted by start. Two spans overlap unless
    one ends at or before the other starts."""
    kept = []
    for s in sorted(spans, key=lambda s: (-(s["end"] - s["start"]), s["start"])):
        if all(s["end"] <= k["start"] or s["start"] >= k["end"] for k in kept):
            kept.append(s)
    return sorted(kept, key=lambda s: s["start"])


def detect(text):
    """Given: pattern spans {type, start, end, text}. Checksums and rules filter the matches."""
    spans = []

    def add(kind, m):
        spans.append({"type": kind, "start": m.start(), "end": m.end(), "text": m.group(0)})

    for m in EMAIL.finditer(text):
        add("EMAIL", m)
    for m in IBAN.finditer(text):
        if iban_ok(m.group(0)):
            add("IBAN", m)
    for m in SSN.finditer(text):
        if ssn_ok(*m.groups()):
            add("SSN", m)
    for m in CARD.finditer(text):
        digits = re.sub(r"\D", "", m.group(0))
        if 13 <= len(digits) <= 19 and luhn_ok(digits):
            add("CARD", m)
    for m in IPV4.finditer(text):
        if ip_ok(m.group(0)):
            add("IP", m)
    for m in PHONE.finditer(text):
        add("PHONE", m)
    return merge(spans)


def combine(pattern_spans, model_spans):
    """Both together: every pattern span, plus only the model's PERSON and ADDRESS spans, merged."""
    return merge(pattern_spans + [s for s in model_spans if s["type"] in ("PERSON", "ADDRESS")])


def redact(text, spans, pseudonymize=False):
    """Replace each span with [TYPE]. With pseudonymize=True, number them per type in order of
    first appearance, the same number for the same value: [EMAIL_1], [EMAIL_2], [EMAIL_1] ..."""
    out, last, ids = [], 0, {}
    for s in sorted(spans, key=lambda s: s["start"]):
        if s["start"] < last:
            continue
        if pseudonymize:
            key = (s["type"], s["text"])
            if key not in ids:
                ids[key] = sum(1 for k in ids if k[0] == s["type"]) + 1
            tag = f"[{s['type']}_{ids[key]}]"
        else:
            tag = f"[{s['type']}]"
        out += [text[last:s["start"]], tag]
        last = s["end"]
    return "".join(out + [text[last:]])


def covered(value, found):
    """A planted value is gone if any found span covers it, whatever label it got. Allow one
    character of slack on each side: start <= value start + 1 and end >= value end - 1."""
    return any(f["start"] <= value["start"] + 1 and f["end"] >= value["end"] - 1 for f in found)


def still_unsafe(gold, found):
    """Count by message: True if any planted value in `gold` is left uncovered."""
    return any(not covered(g, found) for g in gold)
