#!/usr/bin/env python3
"""Export Apple Contacts birthdays to Google Calendar.

Two modes:

1. No Google access, just make an .ics file:
   python3 sync-apple-contact-birthdays-to-google-calendar.py

2. If you have the gog CLI installed and logged in, create a separate Google
   Calendar directly:
   python3 sync-apple-contact-birthdays-to-google-calendar.py --google --account you@gmail.com

The script reads Apple Contacts locally. It does not upload your contacts
anywhere except birthday calendar events when you pass --google.
"""

from __future__ import annotations

import argparse
import datetime as dt
import hashlib
import json
import os
import subprocess
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any

DEFAULT_CALENDAR_NAME = "Apple Contacts Birthdays"
DEFAULT_OUTPUT = "apple-contact-birthdays.ics"
GOOGLE_SOURCE = "apple-contacts-birthdays-script"
TIMEZONE = "Europe/Berlin"

JXA = r'''
const Contacts = Application('Contacts');
const people = Contacts.people();
let birthdays = [];

for (let i = 0; i < people.length; i++) {
  const person = people[i];
  const birthDate = person.birthDate();
  if (!birthDate) continue;

  const name = person.name() || "Unnamed contact";
  const year = birthDate.getFullYear();

  birthdays.push({
    contactId: person.id(),
    name,
    year,
    month: birthDate.getMonth() + 1,
    day: birthDate.getDate(),
    hasYear: year !== 1604
  });
}

birthdays.sort((a, b) => {
  if (a.month !== b.month) return a.month - b.month;
  if (a.day !== b.day) return a.day - b.day;
  return a.name.localeCompare(b.name);
});

JSON.stringify({ totalContacts: people.length, birthdays });
'''


def read_apple_contacts_birthdays() -> tuple[int, list[dict[str, Any]]]:
    result = subprocess.run(
        ["osascript", "-l", "JavaScript", "-e", JXA],
        check=True,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    data = json.loads(result.stdout)
    return int(data["totalContacts"]), data["birthdays"]


def escape_ics_text(value: str) -> str:
    return (
        value.replace("\\", "\\\\")
        .replace(";", "\\;")
        .replace(",", "\\,")
        .replace("\r\n", "\\n")
        .replace("\n", "\\n")
        .replace("\r", "\\n")
    )


def fold_ics_line(line: str) -> str:
    """Fold one iCalendar line to 75 octets without splitting UTF-8 bytes."""
    encoded = line.encode("utf-8")
    if len(encoded) <= 75:
        return line

    parts: list[str] = []
    current = ""
    current_len = 0

    for char in line:
        char_len = len(char.encode("utf-8"))
        limit = 75 if not parts else 74  # continuation line starts with a space
        if current and current_len + char_len > limit:
            parts.append(current)
            current = char
            current_len = char_len
        else:
            current += char
            current_len += char_len

    if current:
        parts.append(current)

    return "\r\n ".join(parts)


def add_ics_line(lines: list[str], line: str) -> None:
    lines.append(fold_ics_line(line))


def birthday_hash(contact: dict[str, Any]) -> str:
    raw = f"{contact['contactId']}|{int(contact['month']):02d}-{int(contact['day']):02d}|{contact.get('year')}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def stable_uid(contact: dict[str, Any]) -> str:
    return f"apple-contact-birthday-{birthday_hash(contact)[:24]}@local"


def google_event_id(contact: dict[str, Any]) -> str:
    # Google Calendar event IDs may contain letters, digits, underscores and dashes.
    return f"applebd{birthday_hash(contact)[:28]}"


def start_year_for(month: int, day: int) -> int:
    today = dt.date.today()
    year = today.year

    if (month, day) < (today.month, today.day):
        year += 1

    if month == 2 and day == 29:
        while True:
            try:
                dt.date(year, month, day)
                return year
            except ValueError:
                year += 1

    return year


def yyyymmdd(year: int, month: int, day: int) -> str:
    return dt.date(year, month, day).strftime("%Y%m%d")


def iso_date(year: int, month: int, day: int) -> str:
    return dt.date(year, month, day).isoformat()


def next_iso_date(year: int, month: int, day: int) -> str:
    return (dt.date(year, month, day) + dt.timedelta(days=1)).isoformat()


def birthday_description(contact: dict[str, Any]) -> str:
    if contact["hasYear"]:
        return f"Imported from Apple Contacts. Birth year in Apple Contacts: {int(contact['year'])}."
    return "Imported from Apple Contacts. Apple Contacts does not store a birth year for this person."


def build_ics(birthdays: list[dict[str, Any]], calendar_name: str) -> str:
    now = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    lines: list[str] = []

    add_ics_line(lines, "BEGIN:VCALENDAR")
    add_ics_line(lines, "VERSION:2.0")
    add_ics_line(lines, "PRODID:-//Apple Contacts Birthday Export//mahir.is//EN")
    add_ics_line(lines, "CALSCALE:GREGORIAN")
    add_ics_line(lines, "METHOD:PUBLISH")
    add_ics_line(lines, f"X-WR-CALNAME:{escape_ics_text(calendar_name)}")
    add_ics_line(lines, "X-WR-CALDESC:Birthdays exported from Apple Contacts")

    for contact in birthdays:
        month = int(contact["month"])
        day = int(contact["day"])
        start_year = start_year_for(month, day)
        name = str(contact["name"])

        add_ics_line(lines, "BEGIN:VEVENT")
        add_ics_line(lines, f"UID:{stable_uid(contact)}")
        add_ics_line(lines, f"DTSTAMP:{now}")
        add_ics_line(lines, f"SUMMARY:{escape_ics_text('Birthday: ' + name)}")
        add_ics_line(lines, f"DESCRIPTION:{escape_ics_text(birthday_description(contact))}")
        add_ics_line(lines, f"DTSTART;VALUE=DATE:{yyyymmdd(start_year, month, day)}")
        add_ics_line(lines, "DURATION:P1D")
        add_ics_line(lines, "RRULE:FREQ=YEARLY")
        add_ics_line(lines, "TRANSP:TRANSPARENT")
        add_ics_line(lines, "STATUS:CONFIRMED")
        add_ics_line(lines, "END:VEVENT")

    add_ics_line(lines, "END:VCALENDAR")
    return "\r\n".join(lines) + "\r\n"


def write_ics(birthdays: list[dict[str, Any]], out_path: Path, calendar_name: str) -> None:
    out_path.write_text(build_ics(birthdays, calendar_name), encoding="utf-8", newline="")


def autodetect_gog_account() -> str:
    result = subprocess.run(
        ["gog", "auth", "tokens", "list", "--json"],
        check=True,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    keys = json.loads(result.stdout).get("keys", [])
    emails = sorted({key.split(":")[-1] for key in keys if key.startswith("token:") and "@" in key})
    if len(emails) == 1:
        return emails[0]
    if not emails:
        raise SystemExit("gog is installed, but I could not find a logged-in Google account. Run `gog login you@gmail.com` first.")
    raise SystemExit("Multiple gog accounts found. Re-run with `--account you@gmail.com`. Found: " + ", ".join(emails))


def export_gog_refresh_token(account: str) -> str:
    fd, path = tempfile.mkstemp(prefix="gog-token-", suffix=".json")
    os.close(fd)
    try:
        subprocess.run(
            ["gog", "auth", "tokens", "export", account, "--out", path, "--overwrite"],
            check=True,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            text=True,
        )
        with open(path, encoding="utf-8") as f:
            return json.load(f)["refresh_token"]
    finally:
        try:
            os.remove(path)
        except FileNotFoundError:
            pass


def get_google_access_token_from_gog(account: str) -> str:
    credentials_path = Path.home() / "Library/Application Support/gogcli/credentials.json"
    if not credentials_path.exists():
        raise SystemExit(f"Could not find gog credentials at {credentials_path}")

    credentials = json.loads(credentials_path.read_text(encoding="utf-8"))
    refresh_token = export_gog_refresh_token(account)
    body = urllib.parse.urlencode(
        {
            "client_id": credentials["client_id"],
            "client_secret": credentials["client_secret"],
            "refresh_token": refresh_token,
            "grant_type": "refresh_token",
        }
    ).encode("utf-8")
    request = urllib.request.Request(
        "https://oauth2.googleapis.com/token",
        data=body,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
    )
    with urllib.request.urlopen(request, timeout=30) as response:
        return json.loads(response.read().decode("utf-8"))["access_token"]


class GoogleCalendar:
    def __init__(self, access_token: str):
        self.access_token = access_token

    def request(
        self,
        method: str,
        path: str,
        body: dict[str, Any] | None = None,
        query: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        url = "https://www.googleapis.com/calendar/v3" + path
        if query:
            url += "?" + urllib.parse.urlencode(query, doseq=True)

        data = None if body is None else json.dumps(body).encode("utf-8")
        last_error = ""

        for attempt in range(6):
            request = urllib.request.Request(
                url,
                data=data,
                method=method,
                headers={
                    "Authorization": f"Bearer {self.access_token}",
                    "Content-Type": "application/json",
                },
            )
            try:
                with urllib.request.urlopen(request, timeout=30) as response:
                    raw = response.read().decode("utf-8")
                    return json.loads(raw) if raw else {}
            except urllib.error.HTTPError as error:
                payload = error.read().decode(errors="replace")
                last_error = f"Google Calendar API {method} {path} failed: HTTP {error.code}: {payload}"
                is_rate_limited = error.code in {429, 403} and "rateLimit" in payload
                if is_rate_limited and attempt < 5:
                    time.sleep(2 ** attempt)
                    continue
                raise RuntimeError(last_error) from error

        raise RuntimeError(last_error or f"Google Calendar API {method} {path} failed")

    def list_calendars(self) -> list[dict[str, Any]]:
        calendars: list[dict[str, Any]] = []
        page_token = None
        while True:
            query = {"maxResults": 250}
            if page_token:
                query["pageToken"] = page_token
            data = self.request("GET", "/users/me/calendarList", query=query)
            calendars.extend(data.get("items", []))
            page_token = data.get("nextPageToken")
            if not page_token:
                return calendars

    def ensure_calendar(self, calendar_name: str) -> tuple[str, bool]:
        for calendar in self.list_calendars():
            if calendar.get("summary") == calendar_name:
                return calendar["id"], False

        calendar = self.request(
            "POST",
            "/calendars",
            body={
                "summary": calendar_name,
                "description": f"Birthdays imported from Apple Contacts on {dt.date.today().isoformat()}.",
                "timeZone": TIMEZONE,
            },
        )
        calendar_id = calendar["id"]
        try:
            self.request(
                "PATCH",
                "/users/me/calendarList/" + urllib.parse.quote(calendar_id, safe=""),
                body={"selected": True},
            )
        except Exception:
            pass
        return calendar_id, True

    def existing_event_ids(self, calendar_id: str) -> set[str]:
        event_ids: set[str] = set()
        page_token = None
        while True:
            query = {
                "maxResults": 2500,
                "showDeleted": False,
                "singleEvents": False,
            }
            if page_token:
                query["pageToken"] = page_token
            data = self.request(
                "GET",
                "/calendars/" + urllib.parse.quote(calendar_id, safe="") + "/events",
                query=query,
            )
            event_ids.update(event["id"] for event in data.get("items", []) if event.get("id"))
            page_token = data.get("nextPageToken")
            if not page_token:
                return event_ids

    def insert_birthday_event(self, calendar_id: str, contact: dict[str, Any]) -> str:
        month = int(contact["month"])
        day = int(contact["day"])
        start_year = start_year_for(month, day)
        event_id = google_event_id(contact)
        event = {
            "id": event_id,
            "summary": f"Birthday: {contact['name']}",
            "description": birthday_description(contact),
            "start": {"date": iso_date(start_year, month, day)},
            "end": {"date": next_iso_date(start_year, month, day)},
            "recurrence": ["RRULE:FREQ=YEARLY"],
            "transparency": "transparent",
            "visibility": "private",
            "reminders": {"useDefault": False},
            "extendedProperties": {
                "private": {
                    "source": GOOGLE_SOURCE,
                    "syncId": event_id,
                    "appleContactHash": hashlib.sha256(str(contact["contactId"]).encode("utf-8")).hexdigest(),
                }
            },
        }
        path = "/calendars/" + urllib.parse.quote(calendar_id, safe="") + "/events"
        try:
            self.request("POST", path, body=event, query={"sendUpdates": "none"})
            return "created"
        except RuntimeError as error:
            if "HTTP 409" in str(error):
                return "skipped"
            raise

    def import_birthdays(self, calendar_name: str, birthdays: list[dict[str, Any]]) -> tuple[str, bool, int, int]:
        calendar_id, created_calendar = self.ensure_calendar(calendar_name)
        existing_ids = self.existing_event_ids(calendar_id)
        created = 0
        skipped = 0
        for contact in birthdays:
            if google_event_id(contact) in existing_ids:
                skipped += 1
                continue
            status = self.insert_birthday_event(calendar_id, contact)
            if status == "created":
                created += 1
                time.sleep(0.2)
            else:
                skipped += 1
        return calendar_id, created_calendar, created, skipped


def print_summary(total_contacts: int, birthdays: list[dict[str, Any]]) -> None:
    with_year = sum(1 for birthday in birthdays if birthday["hasYear"])
    without_year = len(birthdays) - with_year
    print(f"read {total_contacts} Apple Contacts")
    print(f"found {len(birthdays)} birthdays ({with_year} with year, {without_year} month/day only)")


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Export Apple Contacts birthdays to an .ics file, or import them directly with gog."
    )
    parser.add_argument(
        "--out",
        default=DEFAULT_OUTPUT,
        help=f"Output .ics file path. Default: {DEFAULT_OUTPUT}",
    )
    parser.add_argument(
        "--calendar-name",
        default=DEFAULT_CALENDAR_NAME,
        help=f"Calendar name. Default: {DEFAULT_CALENDAR_NAME!r}",
    )
    parser.add_argument(
        "--google",
        action="store_true",
        help="Use gog's stored OAuth token to create a separate Google Calendar directly.",
    )
    parser.add_argument(
        "--account",
        help="Google account email for gog mode. Optional if gog has exactly one account.",
    )
    parser.add_argument(
        "--also-write-ics",
        action="store_true",
        help="In --google mode, also write the .ics file.",
    )
    args = parser.parse_args()

    total_contacts, birthdays = read_apple_contacts_birthdays()
    print_summary(total_contacts, birthdays)

    if args.google:
        account = args.account or autodetect_gog_account()
        access_token = get_google_access_token_from_gog(account)
        google = GoogleCalendar(access_token)
        calendar_id, created_calendar, created, skipped = google.import_birthdays(args.calendar_name, birthdays)
        print(f"google account: {account}")
        print(f"calendar: {args.calendar_name} ({'created' if created_calendar else 'already existed'})")
        print(f"calendar id: {calendar_id}")
        print(f"created {created} events, skipped {skipped} existing events")

        if not args.also_write_ics:
            return 0

    out_path = Path(args.out).expanduser().resolve()
    write_ics(birthdays, out_path, args.calendar_name)
    print(f"wrote {out_path}")
    print("import it in Google Calendar: Settings > Import & export > Import")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
