#!/usr/bin/env python3
"""Read-only OS drive inspection. Python 3.9+, no packages, network or service.
Run against a mounted volume root. JSON goes to stdout; save it OFF the USB.
Never formats, repairs, opens raw devices, or requests elevated privileges.
"""
import argparse
import datetime
import hashlib
import json
import os
from pathlib import Path
import platform
import plistlib
import re
import subprocess
import sys
import unicodedata


def command(args):
    return subprocess.check_output(args, timeout=30)


def fs_name(value):
    value = (value or '').lower()
    return {'fat32': 'FAT32', 'ms-dos fat32': 'FAT32', 'fat16': 'FAT16',
            'ms-dos fat16': 'FAT16', 'exfat': 'exFAT', 'hfs+': 'HFS+',
            'journaled hfs+': 'HFS+', 'ntfs': 'NTFS', 'apfs': 'APFS',
            'case-sensitive journaled hfs+': 'HFS+ case-sensitive'}.get(value, 'unknown')


def disk_info(root, system=None, run=command):
    system = system or platform.system()
    if system == 'Darwin':
        volume = plistlib.loads(run(['diskutil', 'info', '-plist', str(root)]))
        if Path(volume.get('MountPoint', '')).resolve() != root:
            raise ValueError('Select the mounted volume root, not a subfolder.')
        disk = plistlib.loads(run(['diskutil', 'info', '-plist', volume.get('ParentWholeDisk', volume['DeviceIdentifier'])]))
        return {'filesystem': fs_name(volume.get('FilesystemName')),
                'partition': {'GUID_partition_scheme': 'GPT', 'FDisk_partition_scheme': 'MBR', 'Apple_partition_scheme': 'APM'}.get(disk.get('Content'), 'unknown'),
                'volume': volume.get('VolumeName', root.name), 'device': volume['DeviceIdentifier'],
                'sizeBytes': volume.get('TotalSize'), 'source': 'diskutil info -plist'}
    if system == 'Linux':
        mounts = json.loads(run(['findmnt', '--json', '--target', str(root), '--output', 'SOURCE,TARGET,FSTYPE']))['filesystems']
        if len(mounts) != 1 or Path(mounts[0]['target']).resolve() != root:
            raise ValueError('Select a single mounted volume root.')
        mount = mounts[0]
        rows = json.loads(run(['lsblk', '--json', '--inverse', '--paths', '--tree', '--output', 'NAME,TYPE,PTTYPE', mount['source']]))['blockdevices']
        def disks(items):
            return [item for row in items for item in ([row] if row['type'] == 'disk' else disks(row.get('children', [])))]
        parents = disks(rows)
        partition = {'dos': 'MBR', 'gpt': 'GPT', 'mac': 'APM'}.get(parents[0].get('pttype'), 'unknown') if len(parents) == 1 else 'unknown'
        # vfat does not establish FAT12/16/32. Leave ambiguous types unknown.
        return {'filesystem': fs_name(mount['fstype']), 'partition': partition,
                'volume': root.name, 'device': mount['source'], 'source': 'findmnt and lsblk'}
    if system == 'Windows':
        if not re.fullmatch(r'[A-Za-z]:[\\/]?', str(root)):
            raise ValueError('Select a drive-letter root, for example E:\\.')
        letter = str(root)[0]  # Validated alphabetic; never interpolate arbitrary paths.
        script = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $ErrorActionPreference='Stop'; $v=Get-Volume -DriveLetter '" + letter + "'; $p=Get-Partition -DriveLetter '" + letter + "'; $d=$p | Get-Disk; if (@($p).Count -ne 1 -or @($d).Count -ne 1) {throw 'Ambiguous disk'}; @{filesystem=$v.FileSystem; partition=[string]$d.PartitionStyle; volume=$v.FileSystemLabel; device=[string]$d.Number; sizeBytes=$v.Size; source='Get-Volume/Get-Partition/Get-Disk'} | ConvertTo-Json -Compress"
        result = json.loads(run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', script]).decode('utf-8-sig'))
        result['filesystem'] = fs_name(result['filesystem'])
        if result['partition'] not in ('MBR', 'GPT'): result['partition'] = 'unknown'
        return result
    raise ValueError('Supported systems: macOS, Linux and Windows.')


def inventory(root):
    result = []
    def fail(error): raise error
    for parent, directories, files in os.walk(root, followlinks=False, onerror=fail):
        for name in directories + files:
            path = Path(parent) / name
            if path.is_symlink(): raise ValueError('Symlinks are unsupported; cannot bind a complete inventory.')
        for name in files:
            path = Path(parent) / name
            if not path.is_file(): raise ValueError('Non-regular file encountered.')
            result.append([unicodedata.normalize('NFC', path.relative_to(root).as_posix()), path.stat().st_size])
            if len(result) > 250000: raise ValueError('Inventory exceeds 250,000-file safety limit.')
    result.sort(key=lambda row: row[0].encode('utf-8'))
    if len({row[0] for row in result}) != len(result): raise ValueError('Ambiguous normalized filenames.')
    return result


def report(root, inspect=disk_info):
    measured = inspect(root)
    entries = inventory(root)
    digest = hashlib.sha256(json.dumps(entries, ensure_ascii=False, separators=(',', ':')).encode('utf-8')).hexdigest()
    return {'schema': 'usbgood-drive-v1', 'measuredAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
            'platform': platform.system(), **measured,
            'inventory': {'algorithm': 'sha256-path-size-v1', 'sha256': digest, 'fileCount': len(entries)},
            'limitations': ['Inventory matches names and sizes, not file contents or unique physical identity.',
                            'Filesystem consistency, allocation size, physical health and playback are not tested.']}


def save_report(root, destination, result):
    root = root.resolve()
    destination = Path(destination).resolve()
    if destination == root or root in destination.parents:
        raise ValueError('Save the report outside the inspected USB.')
    # Exclusive creation avoids accidentally overwriting any existing user file.
    with destination.open('x', encoding='utf-8') as stream:
        json.dump(result, stream, indent=2, ensure_ascii=False)
        stream.write('\n')


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('volume', help='Mounted USB volume root')
    parser.add_argument('--output', help='New JSON file on the laptop, outside the USB; UTF-8 on all systems')
    args = parser.parse_args()
    try:
        root = Path(args.volume).resolve(strict=True)
        if not root.is_dir(): raise ValueError('Volume must be a directory.')
        result = report(root)
        if args.output: save_report(root, args.output, result)
        else: print(json.dumps(result, indent=2, ensure_ascii=False))
    except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error:
        print(f'Inspection failed; no usable report produced: {error}', file=sys.stderr)
        sys.exit(1)
