#!/usr/bin/env python3
"""Reproduce the 2026 SYNCDATE office-hours model with Python 3.9+.

Run this file without arguments to write CSV/JSON files and a complete ZIP package beside it.
Use --check to verify existing outputs without changing them.
No network, calendars, credentials, or third-party packages are used.
"""

import argparse
import csv
import hashlib
import io
import json
import platform
import zipfile
from datetime import date, datetime, time, timedelta, timezone
from pathlib import Path
from zoneinfo import TZPATH, ZoneInfo


HERE = Path(__file__).resolve().parent
EUROPE = {"Lisbon": "Europe/Lisbon", "London": "Europe/London", "Berlin": "Europe/Berlin"}
US = {"New York": "America/New_York", "Chicago": "America/Chicago",
      "Denver": "America/Denver", "San Francisco": "America/Los_Angeles"}
START, END = date(2026, 10, 19), date(2026, 11, 6)
PERIODS = [("19–23 October 2026", date(2026, 10, 19)),
           ("26–30 October 2026", date(2026, 10, 26)),
           ("2–6 November 2026", date(2026, 11, 2))]
FIELDS = ["date", "europe_city", "europe_zone", "us_city", "us_zone",
          "overlap_hours", "overlap_start_utc", "overlap_end_utc"]
PACKAGE_FILES = ["README.txt", "calculate_overlap.py", "modeled-work-hours.csv",
                 "modeled-work-hours.json", "results-summary.csv", "primary-sources.txt",
                 "pilot/modeled-work-hours.csv", "pilot/modeled-work-hours.json"]


def require(condition, message):
    if not condition:
        raise ValueError(message)


def csv_text(rows, fields):
    buf = io.StringIO(newline="")
    writer = csv.DictWriter(buf, fieldnames=fields, lineterminator="\n")
    writer.writeheader()
    writer.writerows(rows)
    return buf.getvalue()


def package_bytes():
    """A deterministic distribution of the canonical files; no second authored copy."""
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as archive:
        for name in PACKAGE_FILES:
            info = zipfile.ZipInfo(name, date_time=(2026, 10, 30, 0, 0, 0))
            info.create_system = 3
            info.external_attr = 0o100644 << 16
            archive.writestr(info, (HERE / name).read_bytes(), compress_type=zipfile.ZIP_DEFLATED)
    return buf.getvalue()


def weekdays():
    return [START + timedelta(days=i) for i in range((END - START).days + 1)
            if (START + timedelta(days=i)).weekday() < 5]


def office(day, zone):
    return tuple(datetime.combine(day, time(hour), ZoneInfo(zone)).astimezone(timezone.utc)
                 for hour in (9, 17))


def calculate():
    rows = []
    for day in weekdays():
        for eu_city, eu_zone in EUROPE.items():
            for us_city, us_zone in US.items():
                eu_start, eu_end = office(day, eu_zone)
                us_start, us_end = office(day, us_zone)
                start, end = max(eu_start, us_start), min(eu_end, us_end)
                hours = max(0, (end - start).total_seconds() / 3600)
                rows.append(dict(zip(FIELDS, [day.isoformat(), eu_city, eu_zone,
                    us_city, us_zone, hours, start.isoformat() if hours else "",
                    end.isoformat() if hours else ""])))
    return rows


def verify(rows):
    """Check every interval against an independent, fixed-offset arithmetic model."""
    require(len(rows) == 180, "Expected 180 rows")
    require(len({(r["date"], r["europe_city"], r["us_city"]) for r in rows}) == 180,
            "Duplicate or missing date/pair")
    # Primary-source transition dates: EU/UK 25 October, US 1 November.
    # Fixed offsets independently check zoneinfo; they do not generate the results.
    for r in rows:
        day = date.fromisoformat(r["date"])
        eu_offset = (1 if r["europe_city"] == "Berlin" else 0) + (day < date(2026, 10, 25))
        us_offset = {"New York": -5, "Chicago": -6, "Denver": -7,
                     "San Francisco": -8}[r["us_city"]] + (day < date(2026, 11, 1))
        base = datetime.combine(day, time(), timezone.utc)
        s = max(9 - eu_offset, 9 - us_offset)
        e = min(17 - eu_offset, 17 - us_offset)
        require(r["overlap_hours"] == max(0, e - s), f"Wrong hours: {r}")
        require(r["overlap_start_utc"] == ((base + timedelta(hours=s)).isoformat() if e > s else ""),
                f"Wrong start: {r}")
        require(r["overlap_end_utc"] == ((base + timedelta(hours=e)).isoformat() if e > s else ""),
                f"Wrong end: {r}")

    # Retain the complete pilot and verify its rows and JSON examples.
    pilot_path = HERE / "pilot" / "modeled-work-hours.csv"
    with pilot_path.open(newline="", encoding="utf-8") as f:
        pilot = list(csv.DictReader(f))
    require(len(pilot) == len(rows), "Pilot row count differs")
    for expected, actual in zip(pilot, rows):
        for field in FIELDS:
            require(float(expected[field]) == actual[field] if field == "overlap_hours"
                    else expected[field] == actual[field], f"Pilot mismatch: {field}: {actual}")
    pilot_json = json.loads((HERE / "pilot" / "modeled-work-hours.json").read_text())
    lookup = {(r["date"], r["europe_city"], r["us_city"]): r for r in rows}
    for example in pilot_json["examples"]:
        require(example == lookup[(example["date"], example["europe_city"], example["us_city"])],
                f"Pilot JSON example differs: {example}")


def provenance():
    files = {}
    for key in [*EUROPE.values(), *US.values()]:
        for base in TZPATH:
            file = Path(base) / key
            if file.is_file():
                root = Path(base)
                version = root / "+VERSION"
                zi = root / "tzdata.zi"
                files[key] = {"source": "system", "tzpath": str(root),
                    "version": version.read_text().strip() if version.is_file() else
                    (zi.read_text().splitlines()[0] if zi.is_file() else "not exposed"),
                    "sha256": hashlib.sha256(file.read_bytes()).hexdigest()}
                break
        else:
            from importlib import metadata, resources
            data = resources.files("tzdata.zoneinfo").joinpath(*key.split("/")).read_bytes()
            files[key] = {"source": "tzdata package", "version": metadata.version("tzdata"),
                          "sha256": hashlib.sha256(data).hexdigest()}
    return {"python": platform.python_version(), "zones": files}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--check", action="store_true")
    args = parser.parse_args()
    rows = calculate()
    verify(rows)
    lookup = {(r["date"], r["europe_city"], r["us_city"]): r for r in rows}
    summary = []
    for eu_city in EUROPE:
        for us_city in US:
            hours = []
            for label, start in PERIODS:
                week = [lookup[((start + timedelta(days=i)).isoformat(), eu_city, us_city)]
                        for i in range(5)]
                require(len({r["overlap_hours"] for r in week}) == 1, "Week is not constant")
                hours.append(week[0]["overlap_hours"])
            summary.append({"europe_city": eu_city, "us_city": us_city,
                "19_23_oct_hours_per_day": hours[0], "26_30_oct_hours_per_day": hours[1],
                "2_6_nov_hours_per_day": hours[2], "change_during_gap_hours_per_day": hours[1] - hours[0]})
    gained = sum(r["change_during_gap_hours_per_day"] == 1 for r in summary)
    zero_before = sum(r["19_23_oct_hours_per_day"] == 0 for r in summary)
    zero_gap = sum(r["26_30_oct_hours_per_day"] == 0 for r in summary)
    require(gained == 11 and zero_before == 4 and zero_gap == 1, "Summary differs")
    require(all(r["19_23_oct_hours_per_day"] == r["2_6_nov_hours_per_day"] for r in summary),
            "Before and after differ")
    metadata = {"conducted_by": "SYNCDATE", "analysis_date": "2026-10-30",
        "model": "Intersection in UTC of 09:00–17:00 local office hours, Monday–Friday",
        "start_date": START.isoformat(), "end_date": END.isoformat(), "weekdays": 15,
        "pairs": 12, "rows": len(rows), "unit": "modeled shared office hours per city pair per weekday",
        "exclusions": ["holidays", "lunch", "part-time schedules", "actual calendar events"],
        "interpretation": "Public time-zone rule model; not observed availability, meetings, or productivity",
        "interval_rule": "max(0, min(end_A_utc, end_B_utc) - max(start_A_utc, start_B_utc)); touching endpoints = zero",
        "verification": {"pilot_csv_rows_matched": 180, "pilot_json_examples_matched": len(json.loads(
            (HERE / "pilot" / "modeled-work-hours.json").read_text())["examples"]),
            "fixed_offset_rows_matched": 180, "pairs_gaining_one_hour": gained,
            "zero_overlap_pairs_before": zero_before, "zero_overlap_pairs_during_gap": zero_gap,
            "all_pairs_return_to_baseline": True},
        "sources": ["https://www.gov.uk/when-do-the-clocks-change",
                    "https://www.nist.gov/pml/time-and-frequency-division/popular-links/daylight-saving-time-dst",
                    "https://www.boe.es/buscar/doc.php?id=DOUE-Z-2021-70039"],
        "environment": provenance()}
    outputs = {"modeled-work-hours.csv": csv_text(rows, FIELDS),
               "results-summary.csv": csv_text(summary, list(summary[0]))}
    metadata["data_sha256"] = {name: hashlib.sha256(value.encode()).hexdigest()
                               for name, value in outputs.items()}
    outputs["modeled-work-hours.json"] = json.dumps(metadata, indent=2, ensure_ascii=False) + "\n"
    for name, value in outputs.items():
        file = HERE / name
        if args.check:
            saved = file.read_text(encoding="utf-8")
            if name.endswith(".json"):
                # Runtime provenance can differ across machines. Compare the model and data.
                old, new = json.loads(saved), json.loads(value)
                old.pop("environment")
                new.pop("environment")
                require(old == new, f"Stale output: {name}")
            else:
                require(saved == value, f"Stale output: {name}")
        else:
            file.write_text(value, encoding="utf-8")
    package = HERE / "research-package.zip"
    if args.check:
        # An extracted package need not contain a second copy of its own archive.
        if package.exists():
            require(package.read_bytes() == package_bytes(), "Stale ZIP package")
    else:
        package.write_bytes(package_bytes())
    print("PASS: 180 pilot rows, all pilot examples, 180 independent fixed-offset intervals, and 12 three-week summaries")
    print("PASS: 11/12 pairs gain 1 hour/day; zero-overlap pairs 4 → 1 → 4; all pairs return to baseline")
    print(csv_text(summary, list(summary[0])), end="")


if __name__ == "__main__":
    main()
