#!/usr/bin/env python3
"""Post-webinar sync: Zoom API -> HubSpot contact properties.

Pulls registration answers, poll answers and attendance for ONE webinar
occurrence from Zoom and writes them onto EXISTING HubSpot contacts.

Default is a dry run: it writes CSV files and makes no HubSpot call.
Add --apply to write to HubSpot. Add --create-missing to also create
contacts that do not exist yet (off by default).

--mock DIR swaps only the HTTP layer for fixture files in DIR. Every other
function is the same code path as live mode.

Python 3 standard library only. Secrets come from environment variables.
"""
import argparse
import base64
import csv
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

ZOOM_API = "https://api.zoom.us/v2"
ZOOM_OAUTH = "https://zoom.us/oauth/token"
HUBSPOT_API = "https://api.hubapi.com"
BATCH = 100  # HubSpot batch endpoints take up to 100 inputs per call
MAX_PROP_SLUG = 40

FIXED_PROPS = [
    # name, label, HubSpot type, HubSpot fieldType
    ("zoom_last_webinar_id", "Zoom last webinar ID", "string", "text"),
    ("zoom_last_webinar_date", "Zoom last webinar date", "date", "date"),
    ("zoom_attended", "Zoom attended last webinar", "bool", "booleancheckbox"),
    ("zoom_minutes_attended", "Zoom minutes attended", "number", "number"),
]


class HttpError(Exception):
    pass


# ---------------------------------------------------------------- HTTP layer
# The only part mock mode replaces is TRANSPORT.

def _real_transport(method, url, headers, body):
    """Return (status, parsed_json). Retries a few times on HTTP 429."""
    for attempt in range(4):
        req = urllib.request.Request(url, data=body, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=60) as resp:
                raw = resp.read().decode("utf-8")
                return resp.status, (json.loads(raw) if raw else {})
        except urllib.error.HTTPError as e:
            raw = e.read().decode("utf-8", "replace")
            if e.code == 429 and attempt < 3:
                time.sleep(int(e.headers.get("Retry-After", "2")))
                continue
            raise HttpError("%s %s -> HTTP %s: %s" % (method, url.split("?")[0], e.code, raw[:500]))
    raise HttpError("too many retries for %s" % url.split("?")[0])


TRANSPORT = _real_transport


def http_get(url, headers=None):
    return TRANSPORT("GET", url, dict(headers or {}), None)[1]


def http_post(url, headers=None, body=None):
    h = dict(headers or {})
    data = None
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        h["Content-Type"] = "application/json"
    return TRANSPORT("POST", url, h, data)[1]


MOCK_CALLS = []  # (method, path, body) of every mock request, for tests


def make_mock_transport(sample_dir):
    def load(name):
        with open(os.path.join(sample_dir, name), encoding="utf-8") as f:
            return json.load(f)

    def transport(method, url, headers, body):
        path = urllib.parse.urlsplit(url).path
        payload = json.loads(body) if body else None
        MOCK_CALLS.append((method, url, payload))
        if path.endswith("/oauth/token"):
            return 200, {"access_token": "mock-zoom-token"}
        if path.endswith("/instances"):
            return 200, load("instances.json")
        if path.endswith("/registrants"):
            return 200, load("registrants.json")
        if path.endswith("/participants"):
            return 200, load("participants.json")
        if path.endswith("/polls"):
            return 200, load("polls.json")
        if path.endswith("/contacts/batch/read"):
            wanted = {i["id"].lower() for i in payload["inputs"]}
            found = [r for r in load("hubspot_existing.json")["results"]
                     if r["properties"]["email"].lower() in wanted]
            return 200, {"status": "COMPLETE", "results": found}
        if path.endswith("/contacts/batch/update") or path.endswith("/contacts/batch/upsert"):
            return 200, {"status": "COMPLETE", "results": payload["inputs"]}
        raise HttpError("mock has no fixture for %s %s" % (method, path))

    return transport


# ---------------------------------------------------------------- Zoom
def zoom_token(oauth_url, mock=False):
    try:
        acct = "mock" if mock else os.environ["ZOOM_ACCOUNT_ID"]
        cid = "mock" if mock else os.environ["ZOOM_CLIENT_ID"]
        sec = "mock" if mock else os.environ["ZOOM_CLIENT_SECRET"]
    except KeyError as e:
        sys.exit("Missing environment variable %s" % e.args[0])
    basic = base64.b64encode(("%s:%s" % (cid, sec)).encode()).decode()
    url = "%s?%s" % (oauth_url, urllib.parse.urlencode(
        {"grant_type": "account_credentials", "account_id": acct}))
    return http_post(url, {"Authorization": "Basic " + basic})["access_token"]


def encode_uuid(uuid):
    """Zoom: a UUID that starts with '/' or contains '//' must be double URL-encoded."""
    once = urllib.parse.quote(uuid, safe="")
    if uuid.startswith("/") or "//" in uuid:
        return urllib.parse.quote(once, safe="")
    return once


def zoom_paged(url, headers, key, extra=None):
    """Follow next_page_token and return the concatenated list under `key`."""
    items, token = [], ""
    while True:
        q = {"page_size": 300}
        q.update(extra or {})
        if token:
            q["next_page_token"] = token
        page = http_get("%s?%s" % (url, urllib.parse.urlencode(q)), headers)
        items.extend(page.get(key) or [])
        token = page.get("next_page_token") or ""
        if not token:
            return items


def zoom_instances(base, headers, webinar_id):
    page = http_get("%s/past_webinars/%s/instances" % (base, webinar_id), headers)
    return page.get("webinars") or []


def zoom_registrants(base, headers, webinar_id):
    return zoom_paged("%s/webinars/%s/registrants" % (base, webinar_id), headers, "registrants")


def zoom_participants(base, headers, uuid):
    return zoom_paged("%s/report/webinars/%s/participants" % (base, encode_uuid(uuid)),
                      headers, "participants")


def zoom_polls(base, headers, uuid):
    page = http_get("%s/past_webinars/%s/polls" % (base, encode_uuid(uuid)), headers)
    return page.get("questions") or []


# ---------------------------------------------------------------- plan
def slug(text):
    s = re.sub(r"[^a-z0-9]+", "_", (text or "").lower()).strip("_")[:MAX_PROP_SLUG].strip("_")
    return s or "q"


def norm_email(e):
    return (e or "").strip().lower()


def build_plan(webinar_id, occurrence_date, registrants, participants, poll_questions):
    """Return (contacts, unmatched, prop_defs).

    contacts: {email: {property: value}}; unmatched: list of dicts;
    prop_defs: {property_name: (label, type, fieldType)}.
    """
    contacts, unmatched = {}, []
    defs = {n: (l, t, f) for n, l, t, f in FIXED_PROPS}

    def entry(email):
        return contacts.setdefault(email, {})

    for r in registrants:
        email = norm_email(r.get("email"))
        if not email:
            continue
        props = entry(email)
        for cq in r.get("custom_questions") or []:
            title, value = cq.get("title"), cq.get("value")
            if not title or value in (None, ""):
                continue
            name = "zoom_q_" + slug(title)
            defs[name] = ("Zoom Q: " + title, "string", "textarea")
            props[name] = str(value)

    seconds = {}
    names = {}
    for p in participants:
        email = norm_email(p.get("user_email") or p.get("email"))
        if not email:
            unmatched.append({"source": "participant", "name": p.get("name", ""),
                              "join_time": p.get("join_time", ""),
                              "minutes": round((p.get("duration") or 0) / 60),
                              "reason": "no_email"})
            continue
        seconds[email] = seconds.get(email, 0) + int(p.get("duration") or 0)
        names[email] = p.get("name", "")
        entry(email)

    for q in poll_questions:
        email = norm_email(q.get("email"))
        details = q.get("question_details") or []
        if not email:
            unmatched.append({"source": "poll", "name": q.get("name", ""), "join_time": "",
                              "minutes": "", "reason": "no_email (%d poll answers)" % len(details)})
            continue
        props = entry(email)
        grouped = {}
        for d in details:
            if d.get("question") and d.get("answer") not in (None, ""):
                grouped.setdefault(d["question"], []).append(str(d["answer"]))
        for question, answers in grouped.items():
            name = "zoom_poll_" + slug(question)
            defs[name] = ("Zoom Poll: " + question, "string", "textarea")
            props[name] = " | ".join(answers)

    for email, props in contacts.items():
        mins = round(seconds.get(email, 0) / 60)
        props["zoom_last_webinar_id"] = str(webinar_id)
        if occurrence_date:
            props["zoom_last_webinar_date"] = occurrence_date
        props["zoom_attended"] = "true" if email in seconds else "false"
        props["zoom_minutes_attended"] = str(mins)
    return contacts, unmatched, defs


def occurrence_date_from(participants, instance_start=""):
    joins = sorted(p["join_time"] for p in participants if p.get("join_time"))
    stamp = instance_start or (joins[0] if joins else "")
    return stamp[:10]


# ---------------------------------------------------------------- files
def write_csvs(out_dir, contacts, unmatched, defs):
    os.makedirs(out_dir, exist_ok=True)
    fixed = [n for n, _, _, _ in FIXED_PROPS]
    cols = fixed + sorted(n for n in defs if n not in fixed)
    with open(os.path.join(out_dir, "planned_updates.csv"), "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(["email"] + cols)
        for email in sorted(contacts):
            w.writerow([email] + [contacts[email].get(c, "") for c in cols])
    write_unmatched(out_dir, unmatched)
    with open(os.path.join(out_dir, "properties_to_create.csv"), "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(["name", "label", "type", "field_type"])
        for n in cols:
            w.writerow([n] + list(defs[n]))


def write_unmatched(out_dir, unmatched):
    with open(os.path.join(out_dir, "unmatched.csv"), "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(["source", "name", "join_time", "minutes", "reason"])
        for u in unmatched:
            w.writerow([u["source"], u["name"], u["join_time"], u["minutes"], u["reason"]])


# ---------------------------------------------------------------- HubSpot
def hs_headers(mock):
    tok = "mock-hubspot-token" if mock else os.environ.get("HUBSPOT_TOKEN")
    if not tok:
        sys.exit("Missing environment variable HUBSPOT_TOKEN")
    return {"Authorization": "Bearer " + tok}


def chunks(seq, n=BATCH):
    for i in range(0, len(seq), n):
        yield seq[i:i + n]


def hubspot_find_existing(base, headers, emails):
    """Return {email: hubspot_id} for contacts that exist."""
    found = {}
    for part in chunks(sorted(emails)):
        body = {"idProperty": "email", "properties": ["email"],
                "inputs": [{"id": e} for e in part]}
        res = http_post(base + "/crm/v3/objects/contacts/batch/read", headers, body)
        for r in res.get("results") or []:
            found[norm_email(r["properties"].get("email"))] = r["id"]
    return found


def hubspot_apply(base, headers, contacts, create_missing):
    """Write contacts. Returns (written_count, unmatched_rows)."""
    unmatched = []
    if create_missing:
        inputs = [{"idProperty": "email", "id": e, "properties": dict(p, email=e)}
                  for e, p in sorted(contacts.items())]
        for part in chunks(inputs):
            http_post(base + "/crm/v3/objects/contacts/batch/upsert", headers, {"inputs": part})
        return len(inputs), unmatched
    found = hubspot_find_existing(base, headers, contacts.keys())
    inputs = []
    for e, p in sorted(contacts.items()):
        if e in found:
            inputs.append({"id": found[e], "properties": p})
        else:
            unmatched.append({"source": "contact", "name": e, "join_time": "", "minutes": "",
                              "reason": "not_in_hubspot (use --create-missing to create)"})
    for part in chunks(inputs):
        http_post(base + "/crm/v3/objects/contacts/batch/update", headers, {"inputs": part})
    return len(inputs), unmatched


# ---------------------------------------------------------------- main
def parse_args(argv=None):
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("--webinar-id", required=True)
    ap.add_argument("--occurrence-uuid", help="Zoom UUID of one past occurrence")
    ap.add_argument("--latest", action="store_true",
                    help="with no --occurrence-uuid, use the most recent past instance")
    ap.add_argument("--mock", metavar="DIR", help="offline: read fixtures from DIR, no network")
    ap.add_argument("--apply", action="store_true", help="write to HubSpot (default: dry run)")
    ap.add_argument("--create-missing", action="store_true",
                    help="create contacts that do not exist (default: off)")
    ap.add_argument("--out-dir", default="out")
    ap.add_argument("--zoom-base", default=ZOOM_API)
    ap.add_argument("--zoom-oauth-url", default=ZOOM_OAUTH)
    ap.add_argument("--hubspot-base", default=HUBSPOT_API)
    return ap.parse_args(argv)


def main(argv=None):
    global TRANSPORT
    a = parse_args(argv)
    if a.mock:
        TRANSPORT = make_mock_transport(a.mock)
        del MOCK_CALLS[:]
    zh = {"Authorization": "Bearer " + zoom_token(a.zoom_oauth_url, bool(a.mock))}

    start = ""
    uuid = a.occurrence_uuid
    if not uuid:
        inst = sorted(zoom_instances(a.zoom_base, zh, a.webinar_id),
                      key=lambda i: i.get("start_time", ""), reverse=True)
        if not inst:
            sys.exit("No past instances found for webinar %s" % a.webinar_id)
        if not a.latest:
            print("Past instances (newest first). Re-run with --occurrence-uuid UUID or --latest:")
            for i in inst:
                print("  %s  %s" % (i.get("start_time", ""), i["uuid"]))
            return 2
        uuid, start = inst[0]["uuid"], inst[0].get("start_time", "")

    regs = zoom_registrants(a.zoom_base, zh, a.webinar_id)
    parts = zoom_participants(a.zoom_base, zh, uuid)
    polls = zoom_polls(a.zoom_base, zh, uuid)
    date = occurrence_date_from(parts, start)
    contacts, unmatched, defs = build_plan(a.webinar_id, date, regs, parts, polls)
    write_csvs(a.out_dir, contacts, unmatched, defs)
    print("Zoom: %d registrants, %d participant rows, %d poll respondents" %
          (len(regs), len(parts), len(polls)))
    print("Plan: %d contacts, %d unmatched (no email). Files in %s/" %
          (len(contacts), len(unmatched), a.out_dir))

    if not a.apply:
        print("Dry run: no HubSpot call made. Create the properties in "
              "properties_to_create.csv, review planned_updates.csv, then re-run with --apply.")
        return 0

    written, extra = hubspot_apply(a.hubspot_base, hs_headers(bool(a.mock)), contacts,
                                   a.create_missing)
    if extra:
        write_unmatched(a.out_dir, unmatched + extra)
    print("HubSpot: %d contacts written, %d not found and skipped." % (written, len(extra)))
    return 0


if __name__ == "__main__":
    sys.exit(main())
