#!/usr/bin/env python3
"""Gate PTP advertisement on TAI PHC; never write the system/PHC clock.

phc2sys is the only PHC writer. This helper updates only the local ptp4l
timescale metadata, preserving clock quality, leap and traceability flags.
"""
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import time

FIELDS = ('clockClass', 'clockAccuracy', 'offsetScaledLogVariance',
          'currentUtcOffset', 'leap61', 'leap59', 'currentUtcOffsetValid',
          'ptpTimescale', 'timeTraceable', 'frequencyTraceable', 'timeSource')


def offset_seconds():
    value = int(os.environ['PRISM_PTP_UTC_OFFSET'])
    if not 10 <= value <= 127:
        raise ValueError('invalid configured TAI-UTC offset')
    return value


def parse_properties(text):
    values = {}
    for name in FIELDS:
        matches = re.findall(r'^\s*' + name + r'\s+(0x[0-9a-fA-F]+|[0-9]+)\s*$', text, re.M)
        if len(matches) != 1:
            raise ValueError('missing/ambiguous PTP property: ' + name)
        values[name] = int(matches[0], 0) if matches[0].startswith('0x') else int(matches[0])
    for name in ('leap61', 'leap59', 'currentUtcOffsetValid', 'ptpTimescale',
                 'timeTraceable', 'frequencyTraceable'):
        if values[name] not in (0, 1):
            raise ValueError('invalid PTP flag: ' + name)
    return values


def desired_properties(current, offset):
    # Never silently change the offset ptp4l loaded from its active profile.
    if current['currentUtcOffset'] != offset:
        raise ValueError('ptp4l profile and phc2sys offset disagree')
    result = dict(current)
    result.update(currentUtcOffsetValid=1, ptpTimescale=1)
    return result


def pmc(command):
    return subprocess.run(['/usr/sbin/pmc', '-u', '-b', '0', command],
                          text=True, capture_output=True, timeout=3, check=True).stdout


def set_command(desired):
    # linuxptp parses these three members as hexadecimal, even without 0x.
    hex_fields = {'clockAccuracy', 'offsetScaledLogVariance', 'timeSource'}
    return 'SET GRANDMASTER_SETTINGS_NP ' + ' '.join(
        name + ' ' + (hex(desired[name]) if name in hex_fields else str(desired[name]))
        for name in FIELDS)


def measure(offset):
    interface = os.environ['PRISM_PTP_INTERFACE']
    if not re.fullmatch(r'[a-zA-Z0-9_.-]{1,15}', interface):
        raise ValueError('invalid PTP interface')
    devices = list((Path('/sys/class/net') / interface / 'device/ptp').glob('ptp[0-9]*'))
    if len(devices) != 1:
        raise ValueError('expected exactly one PHC for configured interface')
    fd = os.open('/dev/' + devices[0].name, os.O_RDONLY | os.O_CLOEXEC)
    try:
        before = time.time_ns()
        phc = time.clock_gettime_ns((~fd << 3) | 3)
        after = time.time_ns()
    finally:
        os.close(fd)
    bracket = after - before
    residual = phc - (before + after) // 2 - offset * 1000000000
    return dict(residual_ns=residual, read_bracket_ns=bracket,
                aligned=0 <= bracket <= 5000000 and abs(residual) <= 50000000)


def wait_phc(offset, timeout=8):
    deadline = time.monotonic() + timeout
    good = 0
    while time.monotonic() < deadline:
        value = measure(offset)
        good = good + 1 if value['aligned'] else 0
        if good == 3:
            return value
        time.sleep(0.1)
    raise RuntimeError('PHC is not aligned to UTC + configured TAI offset')


def announce(offset):
    wait_phc(offset)
    deadline = time.monotonic() + 8
    current = None
    while time.monotonic() < deadline:
        try:
            current = parse_properties(pmc('GET GRANDMASTER_SETTINGS_NP'))
            break
        except (ValueError, subprocess.SubprocessError):
            time.sleep(0.2)
    if current is None:
        raise RuntimeError('ptp4l management socket not ready')
    desired = desired_properties(current, offset)
    actual = parse_properties(pmc(set_command(desired)))
    if actual != desired:
        raise RuntimeError('PTP timescale SET readback mismatch')
    actual = parse_properties(pmc('GET GRANDMASTER_SETTINGS_NP'))
    if actual != desired:
        raise RuntimeError('PTP timescale GET readback mismatch')
    wait_phc(offset)
    return actual


def main():
    parser = argparse.ArgumentParser()
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument('--wait-phc', action='store_true')
    group.add_argument('--announce', action='store_true')
    group.add_argument('--check', action='store_true')
    args = parser.parse_args()
    offset = offset_seconds()
    if args.check:
        result = parse_properties(pmc('GET GRANDMASTER_SETTINGS_NP'))
        if result != desired_properties(result, offset):
            raise RuntimeError('PTP timescale metadata not valid')
        wait_phc(offset)
    else:
        result = announce(offset) if args.announce else wait_phc(offset)
    print(json.dumps(result, sort_keys=True))


if __name__ == '__main__':
    main()
