#!/usr/bin/env python3
"""A narrow macOS teaching helper. No arbitrary directories or inferred names."""
import argparse
import csv
import ctypes
import datetime
import errno
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import sys
import zipfile

BASE = Path(__file__).resolve().parent

def digest(data):
    return hashlib.sha256(data).hexdigest()

def rows(name):
    with (BASE / name).open(newline='', encoding='utf-8') as f:
        return list(csv.DictReader(f))

INVENTORY = rows('input-inventory.csv')
EVIDENCE = rows('field-evidence.csv')
PACKET_HASH = digest((BASE / 'source-packet.zip').read_bytes())

def canonical(item):
    fields = {x['field']: x for x in EVIDENCE if x['source_id'] == item['source_id']}
    required = ('Document-Type', 'Source-ID', 'Document-Date', 'Project', 'Subject', 'Revision')
    if set(fields) != set(required) or any(fields[k]['status'] != 'verified' for k in required):
        return None
    v = {k: fields[k]['adopted_value'] for k in required}
    if v['Source-ID'] != item['source_id'] or v['Document-Type'] != 'workshop-note':
        return None
    d = v['Document-Date']
    if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', d):
        return None
    try:
        datetime.date.fromisoformat(d)
    except ValueError:
        return None
    if any(not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', v[k]) for k in ('Project', 'Subject', 'Revision')):
        return None
    return '__'.join([d, v['Project'], v['Subject'], 'rev-' + v['Revision'], v['Source-ID'].lower()]) + '.txt'

def basename(name):
    return bool(re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,159}', name)) and name.endswith('.txt')

def validate(plan):
    if len(plan) != len(INVENTORY) or {x['source_id'] for x in plan} != {x['source_id'] for x in INVENTORY}:
        raise ValueError('Plan must contain exactly the eight distinct inventory IDs')
    proposed = {}
    for p in plan:
        if p['proposed_name']:
            proposed.setdefault(p['proposed_name'].casefold(), []).append(p['source_id'])
    result = []
    for item in INVENTORY:
        p = next(x for x in plan if x['source_id'] == item['source_id'])
        reasons = []
        for k in ('old_name', 'sha256'):
            if p[k] != item[k]: reasons.append('inventory_binding_changed')
        for k in ('proposed_name', 'approved_name'):
            if p[k] and not basename(p[k]): reasons.append('invalid_path_or_extension')
        if p['proposed_name'] and len(proposed[p['proposed_name'].casefold()]) > 1:
            reasons.append('duplicate_target')
        expected = canonical(item)
        if expected is None: reasons.append('unverified_required_fields')
        if p['status'] != 'approved': reasons.append('not_approved')
        if expected is not None and (p['approved_name'] != expected or p['proposed_name'] != expected):
            reasons.append('noncanonical_name')
        result.append((item, p, sorted(set(reasons))))
    return result

def exclusive(fd, old, new):
    if sys.platform != 'darwin': raise RuntimeError('macOS only; no fallback')
    libc = ctypes.CDLL(None, use_errno=True)
    fn = libc.renameatx_np
    fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
    fn.restype = ctypes.c_int
    # Apple sys/stdio.h: EXCL=0x4, NOFOLLOW_ANY=0x10.
    if fn(fd, os.fsencode(old), fd, os.fsencode(new), 0x4 | 0x10) != 0:
        e = ctypes.get_errno()
        raise OSError(e, os.strerror(e))

def snapshot(fd, name):
    try:
        st = os.stat(name, dir_fd=fd, follow_symlinks=False)
    except FileNotFoundError:
        return {'state': 'absent'}
    if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1:
        return {'state': 'not_single_regular_file'}
    f = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=fd)
    try:
        b = b''
        while True:
            chunk = os.read(f, 65536)
            if not chunk: break
            b += chunk
        after = os.fstat(f)
        if (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns) != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns):
            return {'state': 'changed_during_read'}
        return {'state': 'regular', 'bytes': len(b), 'sha256': digest(b)}
    finally:
        os.close(f)

def matches(s, item):
    return s.get('sha256') == item['sha256'] and s.get('bytes') == int(item['bytes']) and s['state'] == 'regular'

def identities(item, target):
    # Stable within this fixed packet, irrespective of CSV bytes or commentary.
    source = digest(json.dumps([PACKET_HASH, item['source_id'], item['old_name'],
                               int(item['bytes']), item['sha256']]).encode())
    operation = digest(json.dumps([source, item['old_name'], target]).encode())
    return source, operation

def unresolved(events):
    # The verified owned root already binds the packet and inventory. Unknown
    # source history includes old sparse events; a new plan cannot clear it.
    def same_attempt(intent, result):
        return all(intent.get(k) == result.get(k) for k in
                   ('intent_id', 'source_id', 'source_identity', 'operation_id', 'direction'))
    return any(e['phase'] == 'result' and e['status'] == 'unknown' for e in events) or any(
        e['phase'] == 'intent' and not any(t['phase'] == 'result' and same_attempt(e, t)
                                          for t in events) for e in events)

def root_path(raw):
    p = Path(raw)
    if not p.is_absolute() or p.parent != Path('/private/tmp') or not re.fullmatch(r'fw1-[a-z0-9-]+', p.name):
        raise ValueError('Use a new direct /private/tmp/fw1-... directory')
    return p

def initialize(raw):
    p = root_path(raw)
    # Never adopt an existing directory, even an empty one.
    p.mkdir(mode=0o700)
    (p / 'files').mkdir(mode=0o700)
    with zipfile.ZipFile(BASE / 'source-packet.zip') as z:
        assert sorted(z.namelist()) == sorted(x['old_name'] for x in INVENTORY)
        for item in INVENTORY:
            b = z.read(item['old_name'])
            assert digest(b) == item['sha256'] and len(b) == int(item['bytes'])
            with (p / 'files' / item['old_name']).open('xb') as f: f.write(b)
    marker = {'packet_sha256': PACKET_HASH, 'root_inode': p.stat().st_ino,
              'files_inode': (p / 'files').stat().st_ino, 'device': p.stat().st_dev}
    with (p / '.fw1-owned.json').open('x') as f: json.dump(marker, f)
    (p / 'journal.jsonl').touch(exist_ok=False)
    return {'initialized': str(p), 'files': len(INVENTORY)}

def operate(raw, plan_name, reverse=False):
    p = root_path(raw)
    # plan_name is read-only and explicitly selected, never a directory scan.
    if Path(plan_name).name != plan_name or not plan_name.endswith('.csv'):
        raise ValueError('Plan must be a CSV basename beside this helper')
    plan_bytes = (BASE / plan_name).read_bytes()
    checked = validate(rows(plan_name))
    plan_hash = digest(plan_bytes)
    rfd = os.open(p, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
    fd = jfd = None
    try:
        markerfd = os.open('.fw1-owned.json', os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rfd)
        with os.fdopen(markerfd) as f: marker = json.load(f)
        fd = os.open('files', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=rfd)
        if marker != {'packet_sha256': PACKET_HASH, 'root_inode': os.fstat(rfd).st_ino,
                      'files_inode': os.fstat(fd).st_ino, 'device': os.fstat(rfd).st_dev}:
            raise ValueError('Owned directory identity changed')
        jfd = os.open('journal.jsonl', os.O_RDWR | os.O_APPEND | os.O_NOFOLLOW, dir_fd=rfd)
        fcntl.flock(jfd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        with os.fdopen(os.dup(jfd), 'r') as f: history = [json.loads(line) for line in f if line.strip()]
        def record(event):
            b = (json.dumps(event, sort_keys=True) + '\n').encode()
            if os.write(jfd, b) != len(b): raise OSError('Incomplete journal write; stop and inspect')
            os.fsync(jfd)
            history.append(event)
        outcomes = []
        for item, proposal, reasons in checked:
            old = item['old_name']; new = proposal['approved_name']
            src, dst = (new, old) if reverse else (old, new)
            source_identity, operation_id = identities(item, new)
            row = {'source_id': item['source_id'], 'old_name': old, 'target_name': new,
                   'direction': 'reverse' if reverse else 'forward', 'plan_sha256': plan_hash,
                   'source_identity': source_identity, 'operation_id': operation_id}
            source_events = [e for e in history if e['source_id'] == item['source_id']]
            if unresolved(source_events):
                row.update(status='unknown', reason='unresolved_source_history_no_replay')
            elif reasons:
                row.update(status='blocked', reason=','.join(reasons))
            else:
                events = [e for e in source_events if e.get('source_identity') == source_identity
                          and e.get('operation_id') == operation_id]
                row['before_source'] = snapshot(fd, src); row['before_target'] = snapshot(fd, dst)
                last = [e for e in events if e['phase'] == 'result' and e['status'] in ('applied', 'reversed')]
                last_status = last[-1]['status'] if last else None
                if not reverse and last_status == 'applied' and row['before_source']['state'] == 'absent' and matches(row['before_target'], item):
                    row.update(status='already_applied', reason='confirmed_journal_and_current_bytes')
                elif reverse and last_status == 'reversed' and row['before_source']['state'] == 'absent' and matches(row['before_target'], item):
                    row.update(status='already_reversed', reason='confirmed_journal_and_current_bytes')
                elif reverse and last_status != 'applied':
                    row.update(status='not_applied', reason='no_applicable_confirmed_forward')
                elif not matches(row['before_source'], item):
                    row.update(status='blocked', reason='source_missing_changed_or_not_regular')
                elif row['before_target']['state'] != 'absent':
                    row.update(status='blocked', reason='target_exists')
                else:
                    intent_id = str(len(history) + 1)
                    record(dict(row, phase='intent', intent_id=intent_id))
                    try:
                        exclusive(fd, src, dst)
                        row['after_source'] = snapshot(fd, src); row['after_target'] = snapshot(fd, dst)
                        if row['after_source']['state'] == 'absent' and matches(row['after_target'], item):
                            row.update(status='reversed' if reverse else 'applied', reason='exclusive_rename_and_bytes_confirmed')
                        else:
                            row.update(status='unknown', reason='postcondition_not_confirmed_stop')
                    except OSError as e:
                        # Do not guess no-effect for arbitrary I/O errors.
                        row.update(status='blocked' if e.errno == errno.EEXIST else 'unknown', reason='rename_errno_' + str(e.errno))
                    record(dict(row, phase='result', intent_id=intent_id))
            outcomes.append(row)
            if row['status'] == 'unknown': break
        return {'root': str(p), 'plan': plan_name, 'direction': 'reverse' if reverse else 'forward',
                'rows': outcomes, 'unprocessed_ids': [i['source_id'] for i in INVENTORY[len(outcomes):]],
                'batch_atomic': False}
    finally:
        for n in (jfd, fd, rfd):
            if n is not None: os.close(n)

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('action', choices=['init', 'preview', 'apply', 'reverse'])
    parser.add_argument('root', nargs='?')
    parser.add_argument('--plan', default='rename-plan.csv')
    a = parser.parse_args()
    if Path(a.plan).name != a.plan or not a.plan.endswith('.csv'):
        raise ValueError('Plan must be a CSV basename beside this helper')
    if a.action == 'preview':
        result = [{'source_id': i['source_id'], 'old_name': i['old_name'], 'proposed_name': p['proposed_name'], 'approved_name': p['approved_name'], 'blocked_by': why} for i,p,why in validate(rows(a.plan))]
    elif a.action == 'init': result = initialize(a.root)
    else: result = operate(a.root, a.plan, a.action == 'reverse')
    print(json.dumps(result, indent=2, sort_keys=True))
    # A completed evaluation can contain expected blocked rows. Inspect rows.
    return 0

if __name__ == '__main__':
    try: sys.exit(main())
    except (OSError, ValueError, RuntimeError, AssertionError) as e:
        print(type(e).__name__ + ': ' + str(e), file=sys.stderr)
        sys.exit(2)
