"""
Shared plumbing for the local, read-only readers of the owner's messaging stores
(``imessage_read``, ``whatsapp_read``) and the Contacts importer that feeds them.

Three things live here so the readers agree on them:

- **Handle normalisation** — ``norm_handle`` folds a phone number or e-mail to the
  loose key every reader and map uses (last 10 digits / lower-cased address), so
  ``+1 (555) 123-4567``, ``15551234567`` and ``+15551234567`` are one person.
- **The shared handle map** — ``Agent/scripts/contacts/handles.json`` in the vault,
  written by ``contacts_import --handles`` from the owner's macOS Contacts. Every
  reader loads it *under* its own module-specific ``contacts.json`` (the specific
  file wins on a clash). It is PII and gitignored; ``load_handle_map`` tolerates it
  being absent.
- **``--since`` parsing** — one parser, one error message, one set of accepted
  forms across the readers.

No engine imports here (the readers import *this*), so there are no cycles.
Python 3.9+, standard library only.
"""
import datetime as dt
import json
import re
from pathlib import Path

COCOA_EPOCH = 978307200          # seconds between 1970-01-01 and 2001-01-01 UTC

# Vault-relative home of the shared map (the contacts module's asset folder).
SHARED_HANDLES_REL = Path("Agent") / "scripts" / "contacts" / "handles.json"

def shared_handles_path(vault, config=None):
    """Where the shared map lives for this vault: `contacts.handles_file` from the
    vault's config when set (a relative path is relative to the VAULT, never the cwd),
    else the default. The importer writes here and every reader reads here, so the
    knob cannot split them."""
    vault = Path(vault)
    ct = (config or {}).get("contacts") if isinstance(config, dict) else None
    custom = ct.get("handles_file") if isinstance(ct, dict) else None
    if custom:
        p = Path(str(custom)).expanduser()
        return p if p.is_absolute() else vault / p
    return vault / SHARED_HANDLES_REL


SINCE_FORMS = ("24h | 7d | 2w | today | yesterday | YYYY-MM-DD | "
               "ISO-8601 (2026-08-01T16:03:01, ...+08:00, ...Z)")


def norm_handle(h):
    """Loosely normalise a handle so '+1 (555) 123-4567' and '15551234567' match."""
    if not h:
        return ""
    h = str(h).strip()
    if "@" in h:
        return h.lower()
    digits = re.sub(r"\D", "", h)
    return digits[-10:] if len(digits) >= 10 else digits     # last 10 → country-code agnostic


def load_handle_map(*paths):
    """Merge handle→name maps from ``paths`` in order (a later file overrides an
    earlier one on the same normalised handle). Missing or unparseable files are
    skipped; keys beginning with ``_`` are metadata, not handles; only string
    values are names."""
    merged = {}
    for p in paths:
        if not p:
            continue
        p = Path(p)
        if not p.exists():
            continue
        try:
            raw = json.loads(p.read_text(encoding="utf-8"))
        except (OSError, ValueError):
            continue
        if not isinstance(raw, dict):
            continue
        for k, v in raw.items():
            if isinstance(k, str) and not k.startswith("_") and isinstance(v, str) and v.strip():
                key = norm_handle(k)
                if key:
                    merged[key] = v.strip()
    return merged


def like_pattern(value):
    """Substring LIKE pattern with the wildcards in `value` escaped (ESCAPE '\\')."""
    escaped = value.lower().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
    return "%" + escaped + "%"


def parse_since(spec):
    """Resolve a --since spec to a datetime. Accepts relative offsets, the two day
    words, a bare date, and full ISO-8601 (naive, offset, or 'Z'). Anything else is a
    hard error — never a silent fallback, which would quietly narrow the window."""
    if not spec:
        return None
    spec = spec.strip()
    low = spec.lower()
    now = dt.datetime.now()
    if low == "today":
        return now.replace(hour=0, minute=0, second=0, microsecond=0)
    if low == "yesterday":
        return (now - dt.timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
    m = re.fullmatch(r"(\d+)\s*([hdw])", low)
    if m:
        n, unit = int(m.group(1)), m.group(2)
        return now - {"h": dt.timedelta(hours=n),
                      "d": dt.timedelta(days=n),
                      "w": dt.timedelta(weeks=n)}[unit]
    # ISO-8601 — the form a machine caller hands us (a last-run stamp), so it has to
    # work. Python 3.9's fromisoformat is strict: normalise the space separator, a 'Z'
    # suffix, and a colon-less offset (+0800) first — and never lowercase the spec (a
    # lowercased 'T' separator is exactly what 3.9 rejects). An offset-bearing stamp
    # stays aware; a naive one is local time.
    iso = re.sub(r"\s+", "T", spec)
    iso = re.sub(r"[zZ]$", "+00:00", iso)
    iso = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", iso)
    try:
        return dt.datetime.fromisoformat(iso)
    except ValueError:
        raise SystemExit("--since: cannot parse %r (use %s)" % (spec, SINCE_FORMS))
