#!/usr/bin/env python3
"""
macOS Contacts importer (read-only against Contacts)
====================================================
Reads the owner's local macOS Contacts store (the AddressBook Core Data databases
under ``~/Library/Application Support/AddressBook/``) — or an exported vCard file —
and brings the people the owner chooses into the vault. The `contacts` skill drives
this; a person rarely runs it by hand.

Read-only against Contacts by construction: every store is snapshot-copied (db +
-wal + -shm) to a temp dir and the *copy* is queried; the live database is never
opened, and nothing in Contacts is ever altered. What it writes goes only into the
vault, and only in the explicit write modes below (every one honours --dry-run).

What it can do (all optional, combinable):

    (nothing)          list / --search the cards (text, or --json for the agent)
    --handles          write the SHARED handle map Agent/scripts/contacts/handles.json
                       (phone/e-mail -> name; PII, gitignored) that the iMessage and
                       WhatsApp readers use to show names instead of raw numbers.
                       Hand-written entries in the file are preserved; only entries
                       this importer wrote before are refreshed.
    --enrich           fill the EMPTY frontmatter fields (email, phone, company, role,
                       location, linkedin) of EXISTING People/<Name>.md notes from the
                       matching card — never overwrites a filled field.
    --create NAME      create People/<Name>.md (from People/_template.md) for the named
                       card; repeatable. Ambiguous names are refused with the candidates.
    --create-all       create a note for EVERY person card (orgs skipped unless
                       --include-orgs). Refuses above contacts.max_create (default 200)
                       unless --yes — a whole address book is rarely "people the owner
                       deals with", and the skill asks first.
    --dry-run          print the plan for any write mode; write nothing.

Sources: every ``AddressBook-v22.abcddb`` under the AddressBook folder (the local
store plus each synced source — iCloud, Exchange, Google) is read and merged; ``--vcf``
adds an exported .vcf (Contacts -> File -> Export, or a Google/Outlook export) and
``--no-store`` skips the macOS databases (Linux, or a vault fed only by an export).

Runs on the owner's Mac (Contacts is a per-machine, TCC-protected store) — not an
automation host. Needs macOS Full Disk Access for the app running the assistant.

Optional config — read from Agent/config.json under the `contacts` key, all optional:
    contacts.max_create    cap on --create-all without --yes        (default 200)
    contacts.sources       extra .abcddb / .vcf paths to read       (default [])
    contacts.handles_file  where --handles writes                   (default Agent/scripts/contacts/handles.json)

Usage:
    python -m alexandria_vault.engine.contacts_import --vault .                    # list everyone
    python -m alexandria_vault.engine.contacts_import --vault . --search rivera    # find a card
    python -m alexandria_vault.engine.contacts_import --vault . --handles          # names for the readers
    python -m alexandria_vault.engine.contacts_import --vault . --enrich --dry-run # what would be filled
    python -m alexandria_vault.engine.contacts_import --vault . --create "Alex Rivera"
    python -m alexandria_vault.engine.contacts_import --vault . --vcf ~/Downloads/contacts.vcf --no-store --json

Every run prints a one-line coverage summary to stderr (stores read · cards · people /
orgs · with phone / e-mail), so --json stdout stays parseable. Python 3.9+, stdlib only.
"""
import argparse
import datetime as dt
import json
import os
import quopri
import re
import shutil
import sqlite3
import sys
import tempfile
import unicodedata
from pathlib import Path

from alexandria_vault.engine import consume_vault_arg, resolve_vault
from alexandria_vault.engine.handles import COCOA_EPOCH, SHARED_HANDLES_REL, norm_handle, shared_handles_path  # noqa: F401
from alexandria_vault.vault_core import load_config as _vc_load_config

# `--vault` must land in VAULT_DIR before the module-level config load below.
consume_vault_arg()

VAULT = resolve_vault()
AB_ROOT = Path.home() / "Library" / "Application Support" / "AddressBook"
STORE_NAME = "AddressBook-v22.abcddb"
PEOPLE_REL = Path("People")
TEMPLATE_REL = PEOPLE_REL / "_template.md"

# People frontmatter fields a card can fill, in the template's order.
FILLABLE = ("company", "role", "location", "email", "phone", "linkedin")
# The canonical People frontmatter (AGENTS.md → Note types → People), used when a
# vault has no People/_template.md to copy the key order from.
PEOPLE_KEYS = ("name", "aliases", "company", "role", "location", "email", "phone", "linkedin",
               "relationship", "status", "first_met", "last_contact", "introduced_by",
               "projects", "tags", "author")


# ── config (Core-reads-Config seam; personalization never hardcoded here) ─────
def _load_config():
    """Optional `contacts` tuning from the vault's Config. A missing file or key is
    fine — built-in defaults apply. No identity is read here."""
    cfg = _vc_load_config(VAULT)
    ct = cfg.get("contacts", {}) if isinstance(cfg, dict) else {}
    return ct if isinstance(ct, dict) else {}


CT = _load_config()
MAX_CREATE = int(CT.get("max_create", 200) or 200)
EXTRA_SOURCES = [Path(p).expanduser() for p in (CT.get("sources") or []) if p]
HANDLES_PATH = shared_handles_path(VAULT, {"contacts": CT})


# ── the AddressBook stores ────────────────────────────────────────────────────
FDA_MESSAGE = (
    "Contacts: access not permitted (macOS Full Disk Access).\n"
    "Grant Full Disk Access to the app running your assistant — Claude, or the terminal it runs in:\n"
    "  System Settings -> Privacy & Security -> Full Disk Access -> enable it -> re-open the app.\n"
    "Until then every read of ~/Library/Application Support/AddressBook/ is blocked by macOS TCC."
)


def discover_stores(root=None):
    """Every AddressBook database on this Mac: the local store plus one per synced
    source (iCloud, Exchange, Google …) under Sources/<uuid>/."""
    root = Path(root) if root else AB_ROOT
    if not root.exists():
        return []
    found = []
    if (root / STORE_NAME).exists():
        found.append(root / STORE_NAME)
    src = root / "Sources"
    if src.exists():
        found += sorted(p for p in src.glob("*/" + STORE_NAME) if p.is_file())
    return found


def snapshot_and_open(db_path):
    """Copy db (+wal/shm) to a temp dir and open the copy read-only; the live store is
    never opened. A permission failure on the copy is the TCC gate."""
    db_path = Path(db_path)
    if not db_path.exists():
        raise SystemExit("no Contacts database at %s" % db_path)
    tmp = Path(tempfile.mkdtemp(prefix="contacts-"))
    try:
        for suffix in ("", "-wal", "-shm"):
            src = Path(str(db_path) + suffix)
            if src.exists():
                shutil.copy2(src, tmp / src.name)
    except (PermissionError, OSError):
        shutil.rmtree(tmp, ignore_errors=True)
        raise SystemExit(FDA_MESSAGE)
    copy = tmp / db_path.name
    try:
        con = sqlite3.connect("file:%s?mode=ro" % copy, uri=True)
        con.execute("SELECT 1 FROM ZABCDRECORD LIMIT 1")
    except sqlite3.DatabaseError:
        con = sqlite3.connect(str(copy))        # ro open can fail on WAL recovery; the copy is ours
    con.row_factory = sqlite3.Row
    return con, tmp


def _columns(con, table):
    try:
        return [r[1] for r in con.execute("PRAGMA table_info(%s)" % table)]
    except sqlite3.DatabaseError:
        return []


def _select(con, table, wanted, extra_sql=""):
    """SELECT the `wanted` columns, substituting NULL for any the schema lacks — the
    AddressBook schema drifts by macOS version, and a missing optional column must
    not take the whole import down."""
    have = set(_columns(con, table))
    if not have:
        return []
    cols = ", ".join(("%s AS %s" % (c, c)) if c in have else ("NULL AS %s" % c) for c in wanted)
    return con.execute("SELECT %s FROM %s %s" % (cols, table, extra_sql)).fetchall()


def contact_entities(con):
    """Z_ENT ids of the contact entities (ABCDContact + ABCDSubscribedContact) — groups
    and the info record share ZABCDRECORD and must be left out."""
    try:
        rows = con.execute("SELECT Z_ENT, Z_NAME FROM Z_PRIMARYKEY").fetchall()
    except sqlite3.DatabaseError:
        return set()
    return {r[0] for r in rows if r[1] in ("ABCDContact", "ABCDSubscribedContact")}


def clean_label(label):
    """'_$!<Mobile>!$_' -> 'mobile'; free-text labels pass through lower-cased."""
    if not label:
        return ""
    m = re.fullmatch(r"_\$!<(.+)>!\$_", label.strip())
    return (m.group(1) if m else label).strip().lower()


def cocoa_dt(value):
    if value is None:
        return None
    try:
        return dt.datetime(2001, 1, 1, tzinfo=dt.timezone.utc) + dt.timedelta(seconds=float(value))
    except (ValueError, OverflowError, TypeError):
        return None


def birthday_from(year, yearless):
    """ZBIRTHDAYYEAR is the year (or NULL); ZBIRTHDAYYEARLESS is a Cocoa stamp in a
    reference year carrying the month/day. Returns 'YYYY-MM-DD', 'MM-DD', or None."""
    when = cocoa_dt(yearless)
    if when is None:
        return None
    md = "%02d-%02d" % (when.month, when.day)
    try:
        y = int(year) if year else 0
    except (TypeError, ValueError):
        y = 0
    return "%04d-%s" % (y, md) if 1000 < y < 3000 else md


def _multi(con, table, owner_col, value_cols, records):
    """Attach a multi-value table's rows to their owner record (by Z_PK)."""
    wanted = [owner_col, "ZLABEL"] + list(value_cols)
    try:
        rows = _select(con, table, wanted)
    except sqlite3.DatabaseError:
        rows = []
    for r in rows:
        rec = records.get(r[owner_col])
        if rec is not None:
            yield rec, clean_label(r["ZLABEL"]), r


def read_store(db_path):
    """All contact cards in one AddressBook database, as normalised records."""
    con, tmp = snapshot_and_open(db_path)
    try:
        ents = contact_entities(con)
        wanted = ["Z_PK", "Z_ENT", "ZUNIQUEID", "ZFIRSTNAME", "ZLASTNAME", "ZMIDDLENAME", "ZNICKNAME",
                  "ZORGANIZATION", "ZJOBTITLE", "ZDEPARTMENT", "ZNOTE", "ZBIRTHDAYYEAR",
                  "ZBIRTHDAYYEARLESS", "ZMODIFICATIONDATE", "ZCONTAINERWHERECONTACTISME"]
        records = {}
        for r in _select(con, "ZABCDRECORD", wanted):
            if ents and r["Z_ENT"] not in ents:
                continue
            if not ents and not any(r[c] for c in ("ZFIRSTNAME", "ZLASTNAME", "ZORGANIZATION")):
                continue
            rec = new_record(
                uid=r["ZUNIQUEID"] or ("%s#%s" % (db_path.parent.name, r["Z_PK"])),
                first=r["ZFIRSTNAME"], last=r["ZLASTNAME"], middle=r["ZMIDDLENAME"],
                nickname=r["ZNICKNAME"], org=r["ZORGANIZATION"], title=r["ZJOBTITLE"],
                department=r["ZDEPARTMENT"], note=r["ZNOTE"],
                birthday=birthday_from(r["ZBIRTHDAYYEAR"], r["ZBIRTHDAYYEARLESS"]),
                modified=cocoa_dt(r["ZMODIFICATIONDATE"]),
                is_me=r["ZCONTAINERWHERECONTACTISME"] is not None,
                source=db_path.parent.name if db_path.parent.name != "AddressBook" else "local",
            )
            records[r["Z_PK"]] = rec
        for rec, label, r in _multi(con, "ZABCDEMAILADDRESS", "ZOWNER", ["ZADDRESS"], records):
            if r["ZADDRESS"]:
                rec["emails"].append({"label": label, "value": r["ZADDRESS"].strip()})
        for rec, label, r in _multi(con, "ZABCDPHONENUMBER", "ZOWNER", ["ZFULLNUMBER"], records):
            if r["ZFULLNUMBER"]:
                rec["phones"].append({"label": label, "value": r["ZFULLNUMBER"].strip()})
        for rec, label, r in _multi(con, "ZABCDURLADDRESS", "ZOWNER", ["ZURL"], records):
            if r["ZURL"]:
                rec["urls"].append({"label": label, "value": r["ZURL"].strip()})
        for rec, label, r in _multi(con, "ZABCDSOCIALPROFILE", "ZOWNER",
                                    ["ZSERVICENAME", "ZUSERNAME", "ZDISPLAYNAME"], records):
            handle = r["ZUSERNAME"] or r["ZDISPLAYNAME"]
            if handle:
                rec["social"].append({"service": (r["ZSERVICENAME"] or label or "").lower(),
                                      "username": handle.strip()})
        for rec, label, r in _multi(con, "ZABCDPOSTALADDRESS", "ZOWNER",
                                    ["ZSTREET", "ZCITY", "ZSTATE", "ZZIPCODE", "ZCOUNTRYNAME"], records):
            rec["addresses"].append({
                "label": label,
                "street": (r["ZSTREET"] or "").strip(), "city": (r["ZCITY"] or "").strip(),
                "state": (r["ZSTATE"] or "").strip(), "postal": (r["ZZIPCODE"] or "").strip(),
                "country": (r["ZCOUNTRYNAME"] or "").strip(),
            })
        # Notes moved out of ZABCDRECORD.ZNOTE into ZABCDNOTE on newer macOS.
        for r in _select(con, "ZABCDNOTE", ["ZCONTACT", "ZTEXT"]):
            rec = records.get(r["ZCONTACT"])
            if rec is not None and r["ZTEXT"] and not rec["note"]:
                rec["note"] = r["ZTEXT"].strip()
        return [finish_record(rec) for rec in records.values()]
    finally:
        con.close()
        shutil.rmtree(tmp, ignore_errors=True)


# ── records ───────────────────────────────────────────────────────────────────
def new_record(uid, first=None, last=None, middle=None, nickname=None, org=None, title=None,
               department=None, note=None, birthday=None, modified=None, is_me=False, source=""):
    clean = lambda v: (v or "").strip() if isinstance(v, str) else (v or "")
    return {
        "uid": str(uid), "first": clean(first), "last": clean(last), "middle": clean(middle),
        "nickname": clean(nickname), "org": clean(org), "title": clean(title),
        "department": clean(department), "note": clean(note) if isinstance(note, str) else "",
        "birthday": birthday or None,
        "modified": modified.isoformat(timespec="seconds") if isinstance(modified, dt.datetime) else (modified or None),
        "is_me": bool(is_me), "source": source,
        "emails": [], "phones": [], "urls": [], "social": [], "addresses": [],
    }


def finish_record(rec):
    """Derive the display name and kind; a card is a person when it has a name, an
    organisation when only the company field is filled."""
    name = " ".join(p for p in (rec["first"], rec["last"]) if p).strip()
    if name:
        rec["kind"] = "person"
    elif rec["org"]:
        name, rec["kind"] = rec["org"], "org"
    elif rec["nickname"]:
        name, rec["kind"] = rec["nickname"], "person"
    else:
        name, rec["kind"] = "", "empty"
    rec["name"] = re.sub(r"\s+", " ", name)
    return rec


def _handle_keys(rec):
    return {norm_handle(e["value"]) for e in rec["emails"]} | {norm_handle(p["value"]) for p in rec["phones"]}


def merge_records(lists):
    """Union the stores: same uid → one card; then same name + a shared phone/e-mail →
    one card (the local store and a synced source often hold the same person)."""
    by_uid = {}
    for recs in lists:
        for r in recs:
            if r["kind"] == "empty":
                continue
            if r["uid"] in by_uid:
                _absorb(by_uid[r["uid"]], r)
            else:
                by_uid[r["uid"]] = r
    out = []
    by_name = {}
    for r in by_uid.values():
        key = fold(r["name"])
        keys = _handle_keys(r) - {""}
        target = None
        for cand in by_name.get(key, []):
            if keys & (_handle_keys(cand) - {""}):
                target = cand
                break
        if target is None:
            out.append(r)
            by_name.setdefault(key, []).append(r)
        else:
            _absorb(target, r)
    out.sort(key=lambda r: (r["kind"] != "person", fold(r["last"] or r["name"]), fold(r["name"])))
    return out


def _absorb(into, other):
    for field in ("emails", "phones", "urls", "social", "addresses"):
        seen = {json.dumps(x, sort_keys=True) for x in into[field]}
        for x in other[field]:
            if json.dumps(x, sort_keys=True) not in seen:
                into[field].append(x)
    for field in ("first", "last", "middle", "nickname", "org", "title", "department", "note", "birthday"):
        if not into.get(field) and other.get(field):
            into[field] = other[field]
    into["is_me"] = into["is_me"] or other["is_me"]


def fold(s):
    """Case- and accent-insensitive comparison key."""
    s = unicodedata.normalize("NFKD", s or "")
    s = "".join(ch for ch in s if not unicodedata.combining(ch))
    return re.sub(r"\s+", " ", s).strip().lower()


def matches(rec, query):
    q = fold(query)
    if not q:
        return True
    hay = fold(" ".join([rec["name"], rec["org"], rec["nickname"], rec["title"]]
                        + [e["value"] for e in rec["emails"]]))
    if q in hay:
        return True
    nq = norm_handle(query)
    return bool(nq) and any(nq in norm_handle(p["value"]) for p in rec["phones"])


# ── vCard ─────────────────────────────────────────────────────────────────────
def _unfold(text):
    """Join folded continuation lines (leading space/tab) and vCard 2.1 quoted-printable
    soft breaks (a line ending in '=' continues on the next)."""
    lines = []
    qp_open = False
    for raw in text.splitlines():
        if qp_open and lines:
            lines[-1] = lines[-1][:-1] + raw
        elif raw[:1] in (" ", "\t") and lines:
            lines[-1] += raw[1:]
        else:
            lines.append(raw)
        head = lines[-1].split(":", 1)[0].upper() if lines else ""
        qp_open = "QUOTED-PRINTABLE" in head and lines[-1].endswith("=")
    return lines


def _decode_value(value, params):
    """Apply ENCODING=QUOTED-PRINTABLE (+ CHARSET) — vCard 2.1 exports of non-ASCII names."""
    enc = (params.get("ENCODING") or [""])[0]
    if enc == "quoted-printable":
        charset = (params.get("CHARSET") or ["utf-8"])[0] or "utf-8"
        try:
            return quopri.decodestring(value.encode("latin-1", "replace")).decode(charset, "replace")
        except (LookupError, ValueError):
            return quopri.decodestring(value.encode("latin-1", "replace")).decode("utf-8", "replace")
    return value


def _unescape(v):
    return (v.replace("\\n", "\n").replace("\\N", "\n").replace("\\,", ",")
             .replace("\\;", ";").replace("\\\\", "\\")).strip()


def _split_unescaped(v, sep=";"):
    out, cur, esc = [], [], False
    for ch in v:
        if esc:
            cur.append("\\" + ch); esc = False
        elif ch == "\\":
            esc = True
        elif ch == sep:
            out.append("".join(cur)); cur = []
        else:
            cur.append(ch)
    out.append("".join(cur))
    return out


def parse_vcf(text, source="vcf"):
    """A tolerant vCard 3.0/4.0 reader (Apple/Google/Outlook exports): FN/N, ORG, TITLE,
    NICKNAME, EMAIL, TEL, URL, ADR, BDAY, NOTE, X-SOCIALPROFILE, and Apple's itemN.
    grouped labels. Anything else is ignored."""
    records, cur, labels, pending = [], None, {}, []

    def flush():
        # A card is closed by END:VCARD, by the next BEGIN:VCARD (a missing END), or by EOF.
        if cur is None:
            return
        for target, lab_group in pending:
            target["label"] = labels.get(lab_group, target["label"])
        records.append(finish_record(cur))

    for line in _unfold(text):
        if ":" not in line:
            continue
        head, _, value = line.partition(":")
        parts = head.split(";")
        key = parts[0]
        group = ""
        if "." in key:
            group, key = key.split(".", 1)
        key = key.upper()
        params = {}
        for p in parts[1:]:
            k, _, v = p.partition("=")
            if v:                                   # TYPE=cell,voice / X-USER=name
                params.setdefault(k.upper(), []).extend(x.strip().lower() for x in v.split(",") if x)
            elif k:                                 # vCard 2.1 bare params: TEL;CELL;PREF
                params.setdefault("TYPE", []).append(k.lower())
        if key == "BEGIN" and value.strip().upper() == "VCARD":
            flush()
            # #1: the synthetic uid is scoped to THIS file — two exports with no UID: lines
            # must never share "card 1" and be merged into one person.
            cur, labels, pending = new_record(uid="%s#%d" % (source, len(records) + 1), source=source), {}, []
            continue
        if cur is None:
            continue
        if key == "END":
            flush()
            cur = None
            continue
        value = _decode_value(value, params)
        typ = params.get("TYPE", [])
        label = ",".join(t for t in typ if t not in ("pref", "voice", "internet")) or ""
        if key == "X-ABLABEL":
            labels[group] = clean_label(_unescape(value))
        elif key == "N":
            n = [_unescape(x) for x in _split_unescaped(value)] + [""] * 5
            cur["last"], cur["first"], cur["middle"] = n[0], n[1], n[2]
        elif key == "FN" and not (cur["first"] or cur["last"]):
            fn = _unescape(value)
            if fn:
                bits = fn.split(" ", 1)
                cur["first"], cur["last"] = bits[0], (bits[1] if len(bits) > 1 else "")
        elif key == "NICKNAME":
            cur["nickname"] = _unescape(value)
        elif key == "ORG":
            o = [_unescape(x) for x in _split_unescaped(value)] + [""]
            cur["org"], cur["department"] = o[0], o[1]
        elif key == "TITLE":
            cur["title"] = _unescape(value)
        elif key == "NOTE":
            cur["note"] = _unescape(value)
        elif key == "BDAY":
            v = _unescape(value)
            m = re.fullmatch(r"(\d{4})-?(\d{2})-?(\d{2})", v) or re.fullmatch(r"--(\d{2})-?(\d{2})", v)
            if m and len(m.groups()) == 3:
                cur["birthday"] = "%s-%s-%s" % m.groups()
            elif m:
                cur["birthday"] = "%s-%s" % m.groups()
        elif key == "UID":
            cur["uid"] = _unescape(value) or cur["uid"]
        elif key in ("EMAIL", "TEL", "URL"):
            entry = {"label": label, "value": _unescape(value)}
            if key == "TEL":
                entry["value"] = re.sub(r"^tel:", "", entry["value"], flags=re.I)
            if entry["value"]:
                cur[{"EMAIL": "emails", "TEL": "phones", "URL": "urls"}[key]].append(entry)
                if group:
                    pending.append((entry, group))
        elif key == "ADR":
            a = [_unescape(x) for x in _split_unescaped(value)] + [""] * 7
            entry = {"label": label, "street": a[2], "city": a[3], "state": a[4],
                     "postal": a[5], "country": a[6]}
            cur["addresses"].append(entry)
            if group:
                pending.append((entry, group))
        elif key == "X-SOCIALPROFILE":
            user = (params.get("X-USER") or [""])[0] or _unescape(value)
            cur["social"].append({"service": (typ[0] if typ else "").lower(), "username": user})
        elif key == "X-ABSHOWAS" and _unescape(value).upper() == "COMPANY":
            cur["first"] = cur["last"] = ""
    flush()
    return records


# ── loading everything ────────────────────────────────────────────────────────
def load_all(stores=None, vcfs=(), use_stores=True, ab_root=None):
    """(records, sources_read). Explicit --store paths replace discovery."""
    lists, sources = [], []
    if use_stores:
        paths = [Path(s) for s in stores] if stores else discover_stores(ab_root)
        paths += [p for p in EXTRA_SOURCES if p.suffix == ".abcddb" and p not in paths]
        for p in paths:
            lists.append(read_store(p))
            sources.append(str(p))
    for v in list(vcfs) + [p for p in EXTRA_SOURCES if p.suffix.lower() == ".vcf"]:
        v = Path(v).expanduser()
        if not v.exists():
            raise SystemExit("no vCard file at %s" % v)
        lists.append(parse_vcf(v.read_text(encoding="utf-8", errors="replace"), source=str(v)))
        sources.append(str(v))
    return merge_records(lists), sources


# ── the vault side ────────────────────────────────────────────────────────────
def require_vault():
    """Write modes need a real vault (Agent/config.json), not the lenient cwd fallback."""
    if not (VAULT / "Agent" / "config.json").exists():
        raise SystemExit("not inside an Alexandria vault (%s has no Agent/config.json) — pass --vault PATH "
                         "or run from inside the vault; the write modes land files in People/ and Agent/."
                         % VAULT)


def primary(entries, prefer=("work", "main", "mobile", "iphone")):
    """The first entry, unless a preferred label exists — the frontmatter holds one
    value; the rest go to the note body."""
    if not entries:
        return None
    for lab in prefer:
        for e in entries:
            if e.get("label") == lab:
                return e
    return entries[0]


# The frontmatter holds ONE e-mail and ONE phone: the work address (a business
# contact's canonical one) and the mobile (how a person is actually reached).
PHONE_PREFER = ("mobile", "iphone", "cell", "main", "work")


def location_of(rec):
    addr = primary(rec["addresses"], prefer=("work", "home"))
    if not addr:
        return ""
    return ", ".join(p for p in (addr["city"], addr["country"]) if p)


def linkedin_of(rec):
    for u in rec["urls"]:
        if "linkedin.com" in u["value"].lower():
            return u["value"]
    for s in rec["social"]:
        if s["service"] == "linkedin":
            return s["username"] if s["username"].startswith("http") else "https://www.linkedin.com/in/" + s["username"].lstrip("@")
    return ""


def fills_for(rec):
    """Frontmatter values a card can supply (only non-empty ones)."""
    em, ph = primary(rec["emails"]), primary(rec["phones"], prefer=PHONE_PREFER)
    vals = {
        "company": rec["org"], "role": rec["title"], "location": location_of(rec),
        "email": em["value"] if em else "", "phone": ph["value"] if ph else "",
        "linkedin": linkedin_of(rec),
    }
    return {k: v for k, v in vals.items() if v}


def is_blank(v):
    """An empty frontmatter value, a `<placeholder>`, or an empty list."""
    v = (v or "").strip()
    return v in ("", "[]", "''", '""', "null", "~") or (v.startswith("<") and v.endswith(">"))


_FM_LINE = re.compile(r"^([A-Za-z_][\w-]*):(.*)$")


def split_note(text):
    """(fm_lines, body) for a note with terminated frontmatter, else (None, text)."""
    if not text.startswith("---"):
        return None, text
    end = text.find("\n---", 3)
    if end == -1:
        return None, text
    return text[4:end].split("\n") if end > 3 else [], text[end + 4:]


def fm_dict(fm_lines):
    out = {}
    for line in fm_lines:
        m = _FM_LINE.match(line)
        if m:
            out[m.group(1)] = m.group(2).strip()
    return out


def yaml_scalar(v):
    """Quote a frontmatter scalar when YAML would otherwise misread it — a phone number
    (`+1555…` parses as an int), a leading indicator character, a `: ` or ` #`
    inside, brackets/quotes/commas, or stray whitespace. Plain words, e-mails and URLs
    stay bare, as the template writes them."""
    v = str(v)
    if (v == "" or v.strip() != v or v[:1] in "-?:+@`*&!|>%#'\"[]{}" or v[:1].isdigit()
            or ": " in v or " #" in v or v.endswith(":") or re.search(r'[\[\]{}\'",\x00-\x1f\x7f]', v)):
        return json.dumps(v, ensure_ascii=False)
    return v


def apply_fills(fm_lines, fills):
    """Set the frontmatter lines for `fills` (only where the current value is blank);
    a key the note lacks is appended before the closing fence. Returns
    (new_lines, applied)."""
    applied = {}
    out = list(fm_lines)
    present = fm_dict(out)
    for key, val in fills.items():
        if key in present and not is_blank(present[key]):
            continue
        rendered = "%s: %s" % (key, yaml_scalar(val))
        for i, line in enumerate(out):
            m = _FM_LINE.match(line)
            if m and m.group(1) == key:
                # `email:` followed by an indented continuation (`  - a@x`, a folded scalar)
                # is a filled field in block form — never rewrite it.
                if i + 1 < len(out) and re.match(r"\s+\S", out[i + 1]):
                    break
                out[i] = rendered
                applied[key] = val
                break
        else:
            out.append(rendered)
            applied[key] = val
    return out, applied


def aliases_of(fm):
    raw = fm.get("aliases", "")
    m = re.fullmatch(r"\[(.*)\]", raw.strip())
    if not m:
        return []
    return [a.strip().strip("'\"") for a in m.group(1).split(",") if a.strip()]


def people_notes(people_dir):
    for p in sorted(people_dir.glob("*.md")):
        if p.name.startswith("_") or p.stem == people_dir.name:
            continue                                  # the template + the folder note
        yield p


def match_card(records, names, aliases=()):
    """Cards for a note, in tiers: a card whose NAME equals the note's name/filename
    wins; only when none does are `aliases` tried, against card names and nicknames.
    Tiering keeps "Alex" in a note's aliases from pulling in Alexander Brown (nickname
    Alex) beside the exact-name card. Returns every card of the winning tier — the
    caller treats more than one distinct card as ambiguous."""
    pool = [r for r in records if r["kind"] == "person" and not r["is_me"]]
    keys = {fold(n) for n in names if n}
    exact = [r for r in pool if fold(r["name"]) in keys]
    if exact:
        return exact
    akeys = {fold(a) for a in aliases if a}
    return [r for r in pool if fold(r["name"]) in akeys or (r["nickname"] and fold(r["nickname"]) in akeys | keys)]


def enrich_people(records, people_dir, dry_run):
    """Fill empty frontmatter fields of existing People notes from the matching card."""
    report = []
    for note in people_notes(people_dir):
        text = note.read_text(encoding="utf-8")
        fm_lines, body = split_note(text)
        if fm_lines is None:
            continue
        fm = fm_dict(fm_lines)
        cands = match_card(records, [fm.get("name", "").strip().strip("'\""), note.stem], aliases_of(fm))
        if not cands:
            continue
        if len({c["uid"] for c in cands}) > 1:
            report.append({"note": note.name, "status": "ambiguous",
                           "candidates": [describe(c) for c in cands]})
            continue
        card = cands[0]
        new_lines, applied = apply_fills(fm_lines, fills_for(card))
        if not applied:
            report.append({"note": note.name, "status": "complete", "card": card["name"]})
            continue
        if not dry_run:
            note.write_text("---\n" + "\n".join(new_lines) + "\n---" + body, encoding="utf-8")
        report.append({"note": note.name, "status": "would fill" if dry_run else "filled",
                       "card": card["name"], "fields": applied})
    return report


def safe_filename(name):
    """A People filename from a card name: no path separators, no control characters,
    no leading dot, bounded length — a card name can never walk out of People/."""
    n = unicodedata.normalize("NFC", name or "")
    n = re.sub(r'[\\/:*?"<>|\x00-\x1f\x7f]', " ", n)
    n = re.sub(r"\s+", " ", n)
    n = re.sub(r"^[\s.]+|[\s.]+$", "", n)                 # no leading dot (hidden file), no stray edges
    while len(n.encode("utf-8")) > 200:                     # APFS: 255 bytes incl. ".md"; CJK is 3 bytes/char
        n = n[:-1]
    return re.sub(r"[\s.]+$", "", n)


def template_keys(people_dir):
    tpl = people_dir / "_template.md"
    if tpl.exists():
        fm_lines, _ = split_note(tpl.read_text(encoding="utf-8"))
        if fm_lines:
            keys = [m.group(1) for m in (_FM_LINE.match(l) for l in fm_lines) if m]
            if "name" in keys:
                return keys
    return list(PEOPLE_KEYS)


def render_person(rec, keys, today):
    """A new People note in the vault's own frontmatter shape, filled from the card."""
    fills = fills_for(rec)
    values = {
        "name": rec["name"], "aliases": ("[%s]" % yaml_scalar(rec["nickname"])) if rec["nickname"] else "[]",
        "status": "active", "projects": "[]", "tags": "[contact]", "author": "alexandria",
    }
    values.update(fills)
    fm = []
    for k in keys:
        v = values.get(k, "")
        if k in ("aliases", "projects", "tags"):
            fm.append("%s: %s" % (k, v or "[]"))
        elif v == "":
            fm.append("%s:" % k)
        else:
            fm.append("%s: %s" % (k, yaml_scalar(v)))
    extras = []
    em, ph = primary(rec["emails"]), primary(rec["phones"], prefer=PHONE_PREFER)
    for e in rec["emails"]:
        if e is not em:
            extras.append("%s e-mail: %s" % (e["label"] or "other", e["value"]))
    for p in rec["phones"]:
        if p is not ph:
            extras.append("%s phone: %s" % (p["label"] or "other", p["value"]))
    for a in rec["addresses"]:
        line = ", ".join(x for x in (a["street"], a["city"], a["state"], a["postal"], a["country"]) if x)
        if line:
            extras.append("%s address: %s" % (a["label"] or "address", line))
    for u in rec["urls"]:
        if u["value"] != fills.get("linkedin"):
            extras.append("%s: %s" % (u["label"] or "url", u["value"]))
    for s in rec["social"]:
        if s["service"] != "linkedin":
            extras.append("%s: %s" % (s["service"] or "social", s["username"]))
    if rec["department"]:
        extras.append("department: %s" % rec["department"])
    if rec["birthday"]:
        extras.append("birthday: %s" % rec["birthday"])
    notes = ["- " + x for x in extras]
    if rec["note"]:
        notes.append("- Contacts note: " + rec["note"].replace("\n", " / "))
    body = [
        "---", *fm, "---", "",
        "# " + rec["name"], "",
        "## Context",
        "Imported from macOS Contacts on %s — who they are and why they matter to the owner is still to be written." % today,
        "",
        "## History", "",
        "## Open threads", "",
        "## Notes",
    ] + notes + [""]
    return "\n".join(body)


def create_people(records, people_dir, dry_run, today=None):
    """Create People/<Name>.md per record; an existing note is left alone."""
    today = today or dt.date.today().isoformat()
    keys = template_keys(people_dir)
    report = []
    for rec in records:
        fname = safe_filename(rec["name"])
        if not fname:
            report.append({"card": rec["name"], "status": "skipped", "why": "no usable name"})
            continue
        path = people_dir / (fname + ".md")
        if path.exists():
            report.append({"card": rec["name"], "note": path.name, "status": "exists"})
            continue
        if not dry_run:
            people_dir.mkdir(parents=True, exist_ok=True)
            path.write_text(render_person(rec, keys, today), encoding="utf-8")
        report.append({"card": rec["name"], "note": path.name,
                       "status": "would create" if dry_run else "created"})
    return report


def resolve_create_names(records, names):
    """Each --create NAME → exactly one card, or a hard error naming the candidates."""
    chosen, seen = [], set()
    for name in names:
        exact = match_card(records, [name])
        if not exact:
            q = fold(name)
            exact = [r for r in records if r["kind"] == "person" and not r["is_me"] and q in fold(r["name"])]
        uids = {r["uid"] for r in exact}
        if not exact:
            raise SystemExit("--create %r: no such card in Contacts (try --search)" % name)
        if len(uids) > 1:
            raise SystemExit("--create %r is ambiguous — candidates:\n  %s" % (
                name, "\n  ".join(describe(r) for r in exact)))
        if exact[0]["uid"] not in seen:
            seen.add(exact[0]["uid"])
            chosen.append(exact[0])
    return chosen


# ── the shared handle map ─────────────────────────────────────────────────────
GITIGNORE_LINE = "handles.json"


def write_handles(records, path, dry_run):
    """Refresh the shared handle map: entries this importer wrote before are replaced,
    hand-written ones are kept (and win over an import on the same handle). The file
    is PII — a .gitignore beside it is ensured, whatever module shipped the folder."""
    path = Path(path)
    existing = {}
    if path.exists():
        try:
            existing = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, ValueError):
            existing = {}
        if not isinstance(existing, dict):
            existing = {}
    imported_before = set(existing.get("_imported") or [])
    manual = {k: v for k, v in existing.items()
              if not k.startswith("_") and k not in imported_before and isinstance(v, str)}
    manual_keys = {norm_handle(k) for k in manual}
    fresh = {}
    for rec in records:
        if rec["is_me"] or not rec["name"]:
            continue
        for entry in rec["emails"] + rec["phones"]:
            raw = entry["value"].strip()
            key = norm_handle(raw)
            if not key or key in manual_keys:
                continue
            store_key = raw.lower() if "@" in raw else ("+" if raw.lstrip().startswith("+") else "") + re.sub(r"\D", "", raw)
            if store_key and store_key not in fresh:
                fresh[store_key] = rec["name"]
    out = {"_comment": "handle -> name for the iMessage/WhatsApp readers. Written by "
                       "contacts_import --handles from macOS Contacts; entries listed under "
                       "_imported are refreshed on every import, anything else here is yours "
                       "and kept. PII — gitignored; never commit this file."}
    out.update(manual)
    out.update(fresh)
    out["_imported"] = sorted(fresh)
    summary = {"path": str(path), "imported": len(fresh), "manual_kept": len(manual),
               "people": len({v for v in fresh.values()})}
    if dry_run:
        return summary
    path.parent.mkdir(parents=True, exist_ok=True)
    gi = path.parent / ".gitignore"
    lines = gi.read_text(encoding="utf-8").splitlines() if gi.exists() else []
    if GITIGNORE_LINE not in [l.strip() for l in lines]:
        gi.write_text("\n".join(lines + ["# The handle map is PII — never committed.", GITIGNORE_LINE]) + "\n",
                      encoding="utf-8")
    # Atomic: a torn write would parse as {} next run and silently drop every hand-written entry.
    tmp = path.with_name(path.name + ".tmp")
    tmp.write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    os.replace(tmp, path)
    return summary


# ── output ────────────────────────────────────────────────────────────────────
def describe(rec):
    bits = [rec["name"] + (" (me)" if rec["is_me"] else "")]
    if rec["kind"] == "person" and rec["org"]:
        bits.append(rec["org"] + ((" · " + rec["title"]) if rec["title"] else ""))
    elif rec["title"]:
        bits.append(rec["title"])
    if rec["phones"]:
        bits.append(", ".join(p["value"] for p in rec["phones"][:3]))
    if rec["emails"]:
        bits.append(", ".join(e["value"] for e in rec["emails"][:3]))
    return " — ".join(bits)


def coverage(records, sources):
    people = sum(1 for r in records if r["kind"] == "person" and not r["is_me"])
    orgs = sum(1 for r in records if r["kind"] == "org")
    me = sum(1 for r in records if r["is_me"])
    print("contacts_import: sources %d · cards %d (%d people, %d orgs%s) · with phone %d · with e-mail %d"
          % (len(sources), len(records), people, orgs, ", me-card skipped" if me else "",
             sum(1 for r in records if r["phones"]), sum(1 for r in records if r["emails"])),
          file=sys.stderr)


def main():
    ap = argparse.ArgumentParser(description="Import macOS Contacts into the vault (read-only against Contacts).")
    ap.add_argument("--search", help="only cards matching this name / org / e-mail / number")
    ap.add_argument("--json", action="store_true", help="machine-readable output")
    ap.add_argument("--vcf", action="append", default=[], help="also read an exported .vcf (repeatable)")
    ap.add_argument("--store", action="append", default=[],
                    help="read this AddressBook .abcddb instead of discovering them (repeatable)")
    ap.add_argument("--no-store", action="store_true", help="skip the macOS stores (use --vcf only)")
    ap.add_argument("--handles", action="store_true", help="write the shared handle map for the readers")
    ap.add_argument("--handles-file", default=None, help="where --handles writes (default %s)" % HANDLES_PATH)
    ap.add_argument("--enrich", action="store_true", help="fill empty fields of existing People notes")
    ap.add_argument("--create", action="append", default=[], metavar="NAME",
                    help="create People/<Name>.md for this card (repeatable)")
    ap.add_argument("--create-all", action="store_true", help="create a note for every person card")
    ap.add_argument("--include-orgs", action="store_true", help="with --create-all, also company cards")
    ap.add_argument("--yes", action="store_true", help="allow --create-all above contacts.max_create")
    ap.add_argument("--dry-run", action="store_true", help="show what a write mode would do; write nothing")
    args = ap.parse_args()

    writing = args.handles or args.enrich or args.create or args.create_all
    if writing:
        require_vault()

    records, sources = load_all(stores=args.store, vcfs=args.vcf, use_stores=not args.no_store)
    coverage(records, sources)
    if not sources:
        raise SystemExit("no Contacts store found under %s and no --vcf given — is this a Mac with Contacts? "
                         "(Or export a .vcf from Contacts -> File -> Export and pass --vcf.)" % AB_ROOT)

    if not writing:
        shown = [r for r in records if matches(r, args.search or "")]
        if args.json:
            print(json.dumps(shown, ensure_ascii=False, indent=2))
        else:
            for r in shown:
                print(describe(r))
            if not shown:
                print("(no matching cards)")
        return

    report = {"mode": "dry-run" if args.dry_run else "write"}
    if args.handles:
        report["handles"] = write_handles(records, args.handles_file or HANDLES_PATH, args.dry_run)
    people_dir = VAULT / PEOPLE_REL
    if args.enrich:
        report["enrich"] = enrich_people(records, people_dir, args.dry_run)
    to_create = []
    if args.create:
        to_create += resolve_create_names(records, args.create)
    if args.create_all:
        pool = [r for r in records if not r["is_me"] and (r["kind"] == "person" or (args.include_orgs and r["kind"] == "org"))]
        if len(pool) > MAX_CREATE and not args.yes:
            raise SystemExit("--create-all would create %d People notes (cap %d, contacts.max_create). "
                             "A whole address book is rarely 'people the owner deals with' — pick with "
                             "--create NAME, or pass --yes if the owner really wants all of them."
                             % (len(pool), MAX_CREATE))
        seen = {r["uid"] for r in to_create}
        to_create += [r for r in pool if r["uid"] not in seen]
    if to_create:
        report["create"] = create_people(to_create, people_dir, args.dry_run)

    if args.json:
        print(json.dumps(report, ensure_ascii=False, indent=2))
        return
    verb = "would" if args.dry_run else "did"
    if "handles" in report:
        h = report["handles"]
        print("handles: %s write %d handle(s) for %d people to %s (%d hand-written kept)"
              % (verb, h["imported"], h["people"], h["path"], h["manual_kept"]))
    for row in report.get("enrich", []):
        if row["status"] in ("filled", "would fill"):
            print("enrich: %s %s ← %s" % (row["note"], row["status"], ", ".join("%s=%s" % kv for kv in row["fields"].items())))
        elif row["status"] == "ambiguous":
            print("enrich: %s skipped — ambiguous card: %s" % (row["note"], " | ".join(row["candidates"])))
    for row in report.get("create", []):
        print("create: %s — %s%s" % (row.get("note") or row["card"], row["status"],
                                    (" (%s)" % row["why"]) if row.get("why") else ""))
    if not any(k in report for k in ("handles", "enrich", "create")):
        print("nothing to do")


if __name__ == "__main__":
    main()
