#!/usr/bin/env python3
"""Проверочные примеры Небосвода. Python >=3.12, pip install tzdata==2026.3.

Запуск: python time-zone-check.py --json timezone-cases.json --csv timezone-cases.csv
Код AGPL-3.0-or-later; наша таблица CC BY 4.0. IANA tzdb — собственные условия.
Примеры синтетические. Историческое время до 1970 требует проверки источников;
эта выборка воспроизводит базу, а не удостоверяет время конкретного рождения.
"""
import argparse
import csv
from datetime import UTC, date, datetime, time, timedelta
from importlib.resources import files
from io import BytesIO
import json
import struct
from zoneinfo import ZoneInfo
import tzdata

CASES = [
    ("moscow-fold-choice", "Europe/Moscow", "2014-10-26", "01:30", None, "Повтор часа: выбор ещё не сделан"),
    ("moscow-fold-earlier", "Europe/Moscow", "2014-10-26", "01:30", "earlier", "Ранний момент повторяющегося часа"),
    ("moscow-fold-later", "Europe/Moscow", "2014-10-26", "01:30", "later", "Поздний момент повторяющегося часа"),
    ("moscow-gap", "Europe/Moscow", "2011-03-27", "02:30", None, "Пропущенное местное время"),
    ("moscow-after-change", "Europe/Moscow", "2011-03-28", "12:00", None, "Обычный момент после изменения"),
    ("almaty-fold-choice", "Asia/Almaty", "2024-02-29", "23:30", None, "Два момента на границе перехода Казахстана"),
    ("almaty-fold-earlier", "Asia/Almaty", "2024-02-29", "23:30", "earlier", "Ранний момент, прежний offset"),
    ("almaty-fold-later", "Asia/Almaty", "2024-02-29", "23:30", "later", "Поздний момент, новый offset"),
    ("almaty-after-change", "Asia/Almaty", "2024-03-01", "12:00", None, "Обычный момент после изменения"),
    ("new-york-gap", "America/New_York", "2024-03-10", "02:30", None, "Час отсутствует при сезонном переходе"),
    ("new-york-earlier", "America/New_York", "2024-11-03", "01:30", "earlier", "Ранний момент повторяющегося часа"),
    ("new-york-later", "America/New_York", "2024-11-03", "01:30", "later", "Поздний момент повторяющегося часа"),
    ("new-york-short-date", "America/New_York", "2024-03-10", None, None, "Неизвестное время: действительные сутки короче 24 часов"),
    ("new-york-long-date", "America/New_York", "2024-11-03", None, None, "Неизвестное время: действительные сутки длиннее 24 часов"),
    ("sao-paulo-date", "America/Sao_Paulo", "2018-11-04", None, None, "Полночь отсутствует, но местная дата существует"),
    ("sao-paulo-gap", "America/Sao_Paulo", "2018-11-04", "00:30", None, "Отдельный отсутствующий час внутри существующей даты"),
    ("apia-skipped-date", "Pacific/Apia", "2011-12-30", None, None, "Местная дата пропущена целиком"),
    ("lord-howe-short-date", "Australia/Lord_Howe", "2024-10-06", None, None, "Перевод на полчаса, а не на целый час"),
    ("lord-howe-long-date", "Australia/Lord_Howe", "2024-04-07", None, None, "Повтор получаса учитывается в длине суток"),
    ("paris-historical-seconds", "Europe/Paris", "1890-01-01", "12:00", None, "Исторический offset содержит секунды; это не современный UTC+1"),
]


def zone(name):
    if any(part in ("", ".", "..") for part in name.split("/")):
        raise ValueError("unsafe zone key")
    data = files("tzdata.zoneinfo").joinpath(name).read_bytes()
    return ZoneInfo.from_file(BytesIO(data), key=name), data


def transitions(data):
    offset, width = 0, 4
    counts = struct.unpack_from(">6I", data, 20)
    if data[4:5] in (b"2", b"3", b"4"):
        utc, std, leaps, count, types, chars = counts
        offset = 44 + count * 5 + types * 6 + chars + leaps * 8 + std + utc
        width = 8
        counts = struct.unpack_from(">6I", data, offset + 20)
    return struct.unpack_from(f">{counts[3]}{'q' if width == 8 else 'i'}", data, offset + 44)


def day_ranges(day, tz, data):
    start = datetime.combine(day, time.min, UTC)
    end = start + timedelta(days=1)
    lo, hi = start - timedelta(days=2), end + timedelta(days=2)
    boundaries = {lo, hi}
    boundaries.update(datetime.fromtimestamp(t, UTC) for t in transitions(data) if lo.timestamp() < t < hi.timestamp())
    # POSIX-tail transitions for future dates are bracketed hourly, to one second.
    cursor = lo
    while cursor < hi:
        following = min(cursor + timedelta(hours=1), hi)
        previous_offset = cursor.astimezone(tz).utcoffset()
        if following.astimezone(tz).utcoffset() != previous_offset:
            left, right = int(cursor.timestamp()), int(following.timestamp())
            while right - left > 1:
                middle = (left + right) // 2
                if datetime.fromtimestamp(middle, UTC).astimezone(tz).utcoffset() == previous_offset:
                    left = middle
                else:
                    right = middle
            boundaries.add(datetime.fromtimestamp(right, UTC))
        cursor = following
    result = []
    ordered = sorted(boundaries)
    for a, b in zip(ordered, ordered[1:]):
        offset = a.astimezone(tz).utcoffset()
        left, right = max(a, start - offset), min(b, end - offset)
        if left >= right:
            continue
        if result and result[-1][1] == left:
            result[-1] = (result[-1][0], right)
        else:
            result.append((left, right))
    return result


def iso(moment):
    return moment.isoformat().replace("+00:00", "Z")


def calculate_rows():
    if tzdata.__version__ != "2026.3" or tzdata.IANA_VERSION != "2026c":
        raise ValueError("requires pinned tzdata2026.3 / IANA2026c")
    rows = []
    for identifier, name, date_text, time_text, choice, note in CASES:
        tz, data = zone(name)
        day = date.fromisoformat(date_text)
        ranges = day_ranges(day, tz, data)
        row = dict(id=identifier, zone=name, localDate=date_text, localTime=time_text,
                   choice=choice, expected="local_day_ranges", utc=None, offsetSeconds=None,
                   dayLengthHours=sum((b-a).total_seconds() for a, b in ranges)/3600 if ranges else None, note=note)
        if time_text is None:
            row["expected"] = "local_day_ranges" if ranges else "LOCAL_DATE_SKIPPED"
            row["utc"] = "; ".join(f"[{iso(a)}, {iso(b)})" for a, b in ranges) or None
        else:
            naive = datetime.combine(day, time.fromisoformat(time_text))
            candidates = {}
            for fold in (0, 1):
                local = naive.replace(tzinfo=tz, fold=fold)
                utc = local.astimezone(UTC)
                if utc.astimezone(tz).replace(tzinfo=None) == naive:
                    candidates[utc] = local
            ordered = sorted(candidates)
            if not ordered:
                row["expected"] = "LOCAL_TIME_NONEXISTENT"
            elif len(ordered) > 1 and choice is None:
                row["expected"] = "LOCAL_TIME_AMBIGUOUS"
            else:
                utc = ordered[-1 if choice == "later" else 0]
                row.update(expected="resolved", utc=iso(utc), offsetSeconds=int(candidates[utc].utcoffset().total_seconds()))
        rows.append(row)
    return rows


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--json", type=str, required=True)
    parser.add_argument("--csv", type=str, required=True)
    args = parser.parse_args()
    rows = calculate_rows()
    with open(args.json, "w", encoding="utf-8") as output:
        json.dump(dict(packageVersion=tzdata.__version__, sourceVersion=tzdata.IANA_VERSION, rows=rows), output, ensure_ascii=False, indent=2)
        output.write("\n")
    with open(args.csv, "w", encoding="utf-8", newline="") as output:
        writer = csv.DictWriter(output, fieldnames=list(rows[0]))
        writer.writeheader()
        writer.writerows(rows)
    print(json.dumps(dict(cases=len(rows), packageVersion=tzdata.__version__, sourceVersion=tzdata.IANA_VERSION)))


if __name__ == "__main__":
    main()
