#!/usr/bin/env python3
"""Normalise and hex SHA-256 hash Customer Match identifiers locally.

Usage: python3 hash_customer_match.py customers.csv hashed.csv
English headers: Email, Phone, First Name, Last Name, Country, Zip.
Email, Phone and Zip headers may repeat. Blank optional fields stay blank.
Phones need an explicit + or 00 international prefix: no country is guessed.
Write phones without the national trunk 0 (+31 6 1234 5678, not +31 (0)6 ... or +31 06 ...).
Remove name prefixes and suffixes (Mrs., Jr.) before running. Values that already
look like SHA-256 hashes are rejected, so nothing is hashed twice.
Country must be two letters for hashed uploads. Country and Zip stay unhashed.
Only use first-party data you are entitled to share for this purpose.
Reference: https://support.google.com/google-ads/answer/7474263?hl=en
"""
import csv
import hashlib
import re
import sys
from pathlib import Path

HEX_HASH = re.compile(r"[0-9a-f]{64}", re.I)
HEADERS = {"Email", "Phone", "First Name", "Last Name", "Country", "Zip"}
ADDRESS = {"First Name", "Last Name", "Country", "Zip"}


def normalise(header, value):
    value = value.strip()
    if not value:
        return ""
    if header != "Country" and header != "Zip" and HEX_HASH.fullmatch(value):
        raise ValueError("value already looks hashed; upload plain text or hash it only once")
    if header == "Email":
        value = re.sub(r"\s", "", value.lower())
        if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value):
            raise ValueError("invalid email")
        local, domain = value.split("@")
        if domain in ("gmail.com", "googlemail.com"):
            local = local.replace(".", "")
        value = local + "@" + domain
    elif header == "Phone":
        if not re.fullmatch(r"[+0-9().\s-]+", value):
            raise ValueError("invalid phone characters")
        if re.search(r"\(\s*0\s*\)", value):
            raise ValueError("remove the national trunk 0 in brackets, for example +31 (0)6 becomes +31 6")
        value = re.sub(r"[().\s-]", "", value)
        if value.startswith("00"):
            value = "+" + value[2:]
        if not re.fullmatch(r"\+[1-9][0-9]{6,14}", value):
            raise ValueError("phone needs + or 00 and an international country code")
    elif header in ("First Name", "Last Name"):
        value = value.lower()
    elif header == "Country":
        if not re.fullmatch(r"[A-Za-z]{2}", value):
            raise ValueError("country must be a two-letter ISO code")
        return value.upper()
    elif header == "Zip":
        return value
    return hashlib.sha256(value.encode("utf-8")).hexdigest()


def main(src, dst):
    if Path(src).resolve() == Path(dst).resolve():
        raise ValueError("input and output must be different files")
    with open(src, newline="", encoding="utf-8-sig") as source:
        reader = csv.reader(source)
        headers = next(reader, [])
        if not headers or any(h not in HEADERS for h in headers):
            raise ValueError("use only the exact supported English headers")
        if any(headers.count(h) > 1 for h in ("First Name", "Last Name", "Country")):
            raise ValueError("only Email, Phone and Zip headers may repeat")
        if ADDRESS.intersection(headers) and not ADDRESS.issubset(headers):
            raise ValueError("address matching needs First Name, Last Name, Country and Zip")
        if not {"Email", "Phone"}.intersection(headers) and not ADDRESS.issubset(headers):
            raise ValueError("include an email, phone or complete mailing-address header set")
        output = []
        for number, row in enumerate(reader, 2):
            if not row or all(not v.strip() for v in row):
                continue
            if len(row) != len(headers):
                raise ValueError(f"row {number}: column count differs from header")
            try:
                clean = [normalise(h, v) for h, v in zip(headers, row)]
                has_contact = any(v for h, v in zip(headers, clean) if h in ("Email", "Phone"))
                has_address = all(any(v for h, v in zip(headers, clean) if h == key) for key in ADDRESS)
                if not has_contact and not has_address:
                    raise ValueError("no usable email, phone or complete mailing address")
                output.append(clean)
            except ValueError as error:
                # Do not print private identifiers in an error message.
                raise ValueError(f"row {number}: {error}") from None
    if not output:
        raise ValueError("no customer rows")
    # Validate every row before creating output; never silently discard identifiers.
    with open(dst, "x", newline="", encoding="utf-8") as target:
        writer = csv.writer(target)
        writer.writerow(headers)
        writer.writerows(output)
    print(f"{len(output)} rows written; Country and Zip remain unhashed")


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit(__doc__)
    try:
        main(sys.argv[1], sys.argv[2])
    except (ValueError, OSError, csv.Error, UnicodeError) as error:
        sys.exit(str(error))
