"""Measure logical file bytes for a keep-seven full-copy backup fixture.

Creates a fresh directory with tiny synthetic binary files, tests a good and a
truncated candidate, and removes only one file created inside the good case.
No backup repositories, databases, existing application files or network calls.
No disk-full test. Logical-byte arithmetic excludes filesystem overhead.
"""
import argparse
import datetime as dt
import hashlib
import json
from pathlib import Path
import platform
import shutil
import tempfile

KIB = 1024
SIZES_KIB = [128, 144, 96, 160, 112, 176, 128]


def payload(label, size):
    return hashlib.shake_256(label.encode('ascii')).digest(size)


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def inventory(directory):
    return [{'name': p.name, 'logical_bytes': p.stat().st_size, 'sha256': digest(p)}
            for p in sorted(directory.glob('*.bin'))]


def total(items):
    return sum(x['logical_bytes'] for x in items)


def run(out):
    out.mkdir(parents=True, exist_ok=True)
    if shutil.disk_usage(out).free < 64 * 1024 * 1024:
        raise SystemExit('Requires at least 64 MiB free; does not fill the disk.')
    root = Path(tempfile.mkdtemp(prefix='backup-retention-', dir=out))
    results = []
    for mode in ['valid', 'truncated']:
        case = root / mode
        archive, staging, restore = [case / n for n in ['archive', 'staging', 'restore']]
        for d in [archive, staging, restore]:
            d.mkdir(parents=True)
        # Dates are synthetic recovery-point labels, not observed historical runs.
        for day, size in enumerate(SIZES_KIB):
            label = (dt.date(2026, 10, 31) + dt.timedelta(days=day)).isoformat()
            (archive / (label + '.bin')).write_bytes(payload(label, size * KIB))
        before = inventory(archive)
        expected = payload('2026-11-07', 192 * KIB)
        expected_hash = hashlib.sha256(expected).hexdigest()
        candidate = staging / '2026-11-07.bin'
        candidate.write_bytes(expected if mode == 'valid' else expected[:64 * KIB])
        staged_size = candidate.stat().st_size
        candidate_valid = digest(candidate) == expected_hash
        stages = [{'stage': 'before', 'archive_logical_bytes': total(before), 'staging_logical_bytes': 0, 'restore_logical_bytes': 0},
                  {'stage': 'candidate_created', 'archive_logical_bytes': total(before), 'staging_logical_bytes': staged_size, 'restore_logical_bytes': 0}]
        removed = []
        restore_valid = False
        if candidate_valid:
            target = archive / candidate.name
            candidate.replace(target)  # Same fixture filesystem; moves rather than copies.
            full = inventory(archive)
            restored = restore / target.name
            shutil.copyfile(target, restored)
            restore_valid = digest(restored) == expected_hash
            stages.append({'stage': 'restore_checked_before_prune', 'archive_logical_bytes': total(full), 'staging_logical_bytes': 0, 'restore_logical_bytes': restored.stat().st_size})
            if restore_valid:
                to_remove = sorted(archive.glob('*.bin'))[:-7]
                for p in to_remove:
                    assert p.parent.resolve() == archive.resolve() and p.name in {x['name'] for x in before}
                    removed.append(p.name)
                    p.unlink()  # Only the oldest synthetic file from this fresh case.
        after = inventory(archive)
        stages.append({'stage': 'after', 'archive_logical_bytes': total(after), 'staging_logical_bytes': candidate.stat().st_size if candidate.exists() else 0,
                       'restore_logical_bytes': sum(p.stat().st_size for p in restore.glob('*.bin'))})
        checks = {'seven_initial_files': len(before) == 7,
                  'seven_final_files': len(after) == 7,
                  'initial_sum': total(before) == 944 * KIB,
                  'candidate_accepted_iff_complete': candidate_valid == (mode == 'valid'),
                  'restore_gate': restore_valid == (mode == 'valid'),
                  'only_expected_oldest_removed': removed == (['2026-10-31.bin'] if mode == 'valid' else []),
                  'kept_files_unchanged': all(x in after for x in before if x['name'] not in removed),
                  'final_bytes': total(after) == (1008 if mode == 'valid' else 944) * KIB,
                  'invalid_candidate_preserves_archive': mode == 'valid' or before == after}
        results.append({'case': mode, 'before': before, 'after': after, 'candidate_expected_bytes': len(expected),
                        'candidate_actual_bytes': staged_size, 'candidate_expected_sha256': expected_hash,
                        'candidate_valid': candidate_valid, 'restore_valid': restore_valid, 'removed': removed,
                        'stages': stages, 'checks': checks, 'passed': all(checks.values())})
    good = results[0]
    peak = max(s['archive_logical_bytes'] + s['staging_logical_bytes'] + s['restore_logical_bytes'] for s in good['stages'])
    reserve, budget = 256 * KIB, 1500 * KIB
    steady = total(good['after']) + 192 * KIB + reserve
    planned_peak = peak + reserve
    planning = {'unit': 'bytes', 'reserve_assumption_bytes': reserve, 'budget_assumption_bytes': budget,
                'observed_peak_archive_plus_staging_plus_restore_bytes': peak,
                'planned_peak_with_reserve_bytes': planned_peak, 'after_prune_with_restore_and_reserve_bytes': steady,
                'steady_state_fits_assumed_budget': steady <= budget, 'peak_fits_assumed_budget': planned_peak <= budget}
    result = {'completed_at_utc': dt.datetime.now(dt.timezone.utc).isoformat(),
              'environment': {'os': platform.system(), 'kernel': platform.release(), 'python': platform.python_version()},
              'fixture_dates_are_synthetic_labels': True, 'keep_latest_count': 7, 'cases': results, 'planning': planning,
              'passed': all(r['passed'] for r in results) and steady <= budget < planned_peak,
              'scope': 'Two tiny sequential full-copy binary fixtures; logical file lengths and SHA256 readback only. No restic/Borg commands, SQLite restore, real workload, compression/deduplication, filesystem allocated-space measurement, disk-full, crash or offsite recovery test. Reserve and budget are assumptions, not observed capacity.'}
    (root / 'result.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8')
    print(json.dumps({'result_file': str(root / 'result.json'), 'passed': result['passed']}))
    return 0 if result['passed'] else 1


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--out', type=Path, required=True)
    raise SystemExit(run(parser.parse_args().out))
