#!/usr/bin/env python3
"""A daily local clock time versus a fixed elapsed interval.

Run: python3 calendar-day-lab.py [--json calendar-day-results.json]
Needs Python 3.9+ and IANA zone data (system data or the tzdata package).
Calculates examples only: no sleeps, scheduled jobs, or external services.
The datetime model used here does not account for leap seconds.
"""

import argparse
from datetime import datetime, timedelta, timezone
import hashlib
import json
from pathlib import Path
import platform
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, TZPATH


UTC = timezone.utc


def require_aware(value):
    if value.tzinfo is None or value.utcoffset() is None:
        raise ValueError("an aware datetime is required")


def local_candidates(local, zone):
    """Find distinct UTC instants that round-trip to a naive local datetime."""
    if local.tzinfo is not None:
        raise ValueError("local_candidates requires a naive local datetime")
    found = {}
    for fold in (0, 1):
        candidate = local.replace(tzinfo=zone, fold=fold)
        instant = candidate.astimezone(UTC)
        back = instant.astimezone(zone)
        if back.replace(tzinfo=None) == local:
            found[instant] = back
    return [found[instant] for instant in sorted(found)]


def resolve_local(local, zone, ambiguity="reject"):
    if ambiguity not in ("reject", "earlier", "later"):
        raise ValueError("ambiguity must be reject, earlier, or later")
    candidates = local_candidates(local, zone)
    if not candidates:
        raise ValueError("the local clock time does not exist")
    if len(candidates) > 1:
        if ambiguity == "reject":
            raise ValueError("the local clock time is ambiguous")
        return candidates[0] if ambiguity == "earlier" else candidates[-1]
    return candidates[0]


def next_calendar_day(start):
    require_aware(start)
    local_next = start.replace(tzinfo=None) + timedelta(days=1)
    return resolve_local(local_next, start.tzinfo)


def add_elapsed_hours(start, hours):
    require_aware(start)
    return (start.astimezone(UTC) + timedelta(hours=hours)).astimezone(start.tzinfo)


def elapsed_hours(start, end):
    require_aware(start)
    require_aware(end)
    return (end.astimezone(UTC) - start.astimezone(UTC)).total_seconds() / 3600


def data_record(key):
    for base in TZPATH:
        zone_file = Path(base) / key
        if zone_file.is_file():
            markers = {}
            for name in ("+VERSION", "tzdata.zi"):
                marker = Path(base) / name
                if marker.is_file():
                    with marker.open(encoding="utf-8", errors="replace") as stream:
                        markers[name] = stream.readline().strip()
            return {"zone": key, "source": "system TZPATH",
                    "sha256": hashlib.sha256(zone_file.read_bytes()).hexdigest(),
                    "version_markers": markers}
    return {"zone": key, "source": "no system file identified; package fallback may be used",
            "sha256": None, "version_markers": {}}


def expect_rejection(operation, exception=ValueError):
    try:
        operation()
    except exception:
        return
    raise AssertionError("invalid or ambiguous input was accepted")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--json", type=Path)
    options = parser.parse_args()
    new_york = ZoneInfo("America/New_York")
    shanghai = ZoneInfo("Asia/Shanghai")
    intervals = []
    examples = [
        ("NY spring", datetime(2026, 3, 7, 20, tzinfo=new_york), 23, 21),
        ("NY autumn", datetime(2026, 10, 31, 20, tzinfo=new_york), 25, 19),
        ("Shanghai", datetime(2026, 3, 7, 20, tzinfo=shanghai), 24, 20),
    ]
    for label, start, expected_hours, expected_clock in examples:
        calendar_next = next_calendar_day(start)
        elapsed_next = add_elapsed_hours(start, 24)
        wall = (calendar_next - start).total_seconds() / 3600
        elapsed = elapsed_hours(start, calendar_next)
        assert calendar_next.hour == 20 and elapsed == expected_hours and wall == 24
        assert elapsed_next.hour == expected_clock and elapsed_hours(start, elapsed_next) == 24
        intervals.append({"case": label, "zone": start.tzinfo.key,
                          "start": start.isoformat(), "next_local_day": calendar_next.isoformat(),
                          "same_zone_subtraction_hours": wall, "utc_elapsed_hours": elapsed,
                          "after_24_elapsed_hours": elapsed_next.isoformat()})
    gap = datetime(2026, 3, 8, 2, 30)
    assigned_gap = gap.replace(tzinfo=new_york)
    gap_back = assigned_gap.astimezone(UTC).astimezone(new_york)
    assert local_candidates(gap, new_york) == [] and gap_back.replace(tzinfo=None) != gap
    expect_rejection(lambda: resolve_local(gap, new_york))
    repeated = datetime(2026, 11, 1, 1, 30)
    earlier = resolve_local(repeated, new_york, "earlier")
    later = resolve_local(repeated, new_york, "later")
    assert len(local_candidates(repeated, new_york)) == 2
    assert earlier.fold == 0 and later.fold == 1 and elapsed_hours(earlier, later) == 1
    expect_rejection(lambda: resolve_local(repeated, new_york))
    ordinary = datetime(2026, 10, 4, 20)
    assert len(local_candidates(ordinary, new_york)) == 1
    expect_rejection(lambda: resolve_local(ordinary, new_york, "guess"))
    expect_rejection(lambda: add_elapsed_hours(ordinary, 24))
    expect_rejection(lambda: ZoneInfo("Gumi/No_Such_Zone"), ZoneInfoNotFoundError)
    expect_rejection(lambda: next_calendar_day(datetime(2026, 3, 7, 2, 30, tzinfo=new_york)))
    expect_rejection(lambda: next_calendar_day(datetime(2026, 10, 31, 1, 30, tzinfo=new_york)))
    checks = ["ordinary local time", "missing local time rejected", "ambiguous local time policy",
              "invalid policy rejected", "naive interval input rejected", "unknown zone rejected",
              "next-day gap rejected", "next-day ambiguity rejected"]
    report = {"run_date": datetime.now().astimezone().date().isoformat(),
              "python": platform.python_version(), "platform": platform.system(),
              "timezone_data": [data_record(key) for key in ("America/New_York", "Asia/Shanghai")],
              "intervals": intervals,
              "gap": {"local_input": gap.isoformat(), "constructor_result": assigned_gap.isoformat(),
                      "utc_round_trip": gap_back.isoformat(), "valid_candidates": 0},
              "fold": {"local_input": repeated.isoformat(), "earlier": earlier.isoformat(),
                       "earlier_utc": earlier.astimezone(UTC).isoformat(), "later": later.isoformat(),
                       "later_utc": later.astimezone(UTC).isoformat(), "elapsed_hours": 1},
              "boundary_checks": checks}
    print(f"Python {report['python']} on {report['platform']}")
    for item in intervals:
        print(f"{item['case']}: {item['start']} -> {item['next_local_day']}; "
              f"same-zone subtraction={item['same_zone_subtraction_hours']:g}h; "
              f"UTC interval={item['utc_elapsed_hours']:g}h")
        print("  after 24 elapsed hours:", item["after_24_elapsed_hours"])
    print("gap: assigned", assigned_gap.isoformat(), "-> UTC round trip", gap_back.isoformat())
    print("fold: earlier", earlier.isoformat(), "=", report["fold"]["earlier_utc"])
    print("fold: later  ", later.isoformat(), "=", report["fold"]["later_utc"])
    print(f"All {len(intervals)} interval cases and {len(checks)} boundary checks passed.")
    if options.json:
        options.json.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        print("JSON written:", options.json)


if __name__ == "__main__":
    main()
