"""Stopped-stack, all-volume backups. Archives contain secrets; never print them."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tarfile
from pathlib import PurePosixPath


def command(args):
    result = subprocess.run(args, capture_output=True, timeout=300)
    if result.returncode:
        raise RuntimeError('Docker backup operation failed; output suppressed')
    return result.stdout


def digest(path):
    result = hashlib.sha256()
    with path.open('rb') as stream:
        for block in iter(lambda: stream.read(1048576), b''):
            result.update(block)
    return result.hexdigest()


def validate_archive(archive, metadata):
    info = archive.lstat()
    if archive.is_symlink() or not archive.is_file() or info.st_mode & 0o077:
        raise ValueError('Unsafe backup archive')
    if info.st_size != metadata.get('bytes') or digest(archive) != metadata.get('sha256'):
        raise ValueError('Backup archive integrity mismatch')
    with tarfile.open(archive, 'r:') as content:
        for member in content:
            path = PurePosixPath(member.name)
            if path.is_absolute() or '..' in path.parts or not (member.isfile() or member.isdir()):
                raise ValueError('Unsafe or unsupported archive member')


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('action', choices=['backup', 'restore'])
    parser.add_argument('--project', required=True)
    parser.add_argument('--file', action='append', required=True)
    parser.add_argument('--env-file')
    parser.add_argument('--directory', type=Path, required=True)
    args = parser.parse_args()
    if not re.fullmatch(r'[a-z][a-z0-9-]{2,62}', args.project):
        raise ValueError('Invalid Compose project name')
    os.umask(0o077)
    root = args.directory.absolute()
    compose = ['docker', 'compose', '-p', args.project]
    for path in args.file:
        compose += ['-f', str(Path(path).absolute())]
    if args.env_file:
        compose += ['--env-file', str(Path(args.env_file).absolute())]
    config = json.loads(command(compose + ['config', '--format', 'json']))
    volumes = config['volumes']
    if any(not re.fullmatch(r'[a-z0-9_-]+', name) or value.get('external') for name, value in volumes.items()):
        raise ValueError('Only project-owned named volumes are supported')
    image = config['services']['api']['image']
    image_id = json.loads(command(['docker', 'image', 'inspect', image]))[0]['Id']
    image_refs = sorted({service['image'] for service in config['services'].values()})
    image_ids = {reference: json.loads(command(['docker', 'image', 'inspect', reference]))[0]['Id']
                 for reference in image_refs}
    container = ['docker', 'run', '--rm', '--network', 'none', '--read-only',
                 '--security-opt', 'no-new-privileges:true', '--cap-drop', 'ALL',
                 '--cap-add', 'DAC_OVERRIDE', '--entrypoint', 'tar']
    if args.action == 'backup':
        root.mkdir(mode=0o700)  # Refuse existing directories; never overwrite a backup.
        manifest = {'version': 2, 'complete': False, 'project': args.project,
                    'created': datetime.now(timezone.utc).isoformat(), 'image': image_id,
                    'images': image_ids, 'volumes': {}}
        for name, value in volumes.items():
            metadata = json.loads(command(['docker', 'volume', 'inspect', value['name']]))[0]
            if metadata.get('Labels', {}).get('com.docker.compose.project') != args.project:
                raise ValueError('Volume ownership does not match source project')
        command(compose + ['stop', '--timeout', '60'])
        try:
            for name, value in volumes.items():
                archive = root / (name + '.tar')
                with archive.open('xb') as stream:
                    result = subprocess.run(container + ['--mount', 'type=volume,src=' + value['name'] +
                        ',dst=/volume,readonly', image_id, '--numeric-owner', '-C', '/volume', '-cf', '-', '.'],
                        stdout=stream, stderr=subprocess.PIPE, timeout=300)
                if result.returncode:
                    raise RuntimeError('Volume archive failed: ' + name)
                manifest['volumes'][name] = {'sha256': digest(archive), 'bytes': archive.stat().st_size}
            manifest['complete'] = True
            (root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
        finally:
            command(compose + ['up', '-d', '--no-build', '--wait', '--wait-timeout', '240'])
        print('PASS: stopped-stack backup includes all', len(volumes), 'volumes; source restarted.')
    else:
        container += ['--cap-add', 'CHOWN', '--cap-add', 'FOWNER']
        if root.is_symlink() or root.stat().st_mode & 0o077:
            raise ValueError('Backup directory must be private and not a symlink')
        manifest = json.loads((root / 'manifest.json').read_text())
        if manifest.get('version') not in (1, 2) or not manifest.get('complete') or set(manifest['volumes']) != set(volumes):
            raise ValueError('Incomplete or incompatible backup set')
        if manifest.get('image') != image_id:
            raise ValueError('Restore requires the original API image; upgrade only after restoration')
        if manifest['version'] == 2 and manifest.get('images') != image_ids:
            raise ValueError('Restore requires the complete original image set')
        if manifest['project'] == args.project:
            raise ValueError('Restore into a new project, never overwrite the source')
        for name, value in volumes.items():
            existing = subprocess.run(['docker', 'volume', 'inspect', value['name']], capture_output=True)
            if existing.returncode == 0:
                raise ValueError('Restore target volume already exists: ' + name)
            archive = root / (name + '.tar')
            validate_archive(archive, manifest['volumes'][name])
        # Create only stopped containers/empty volumes, then restore every volume
        # before any initializer, migration or runtime service can start.
        command(compose + ['create', '--no-build'])
        for name, value in volumes.items():
            with (root / (name + '.tar')).open('rb') as stream:
                result = subprocess.run(container + ['-i', '--mount', 'type=volume,src=' + value['name'] +
                    ',dst=/volume', image_id, '--numeric-owner', '-C', '/volume', '-xpf', '-'],
                    stdin=stream, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300)
            if result.returncode:
                raise RuntimeError('Restore failed; target remains stopped: ' + name)
        print('PASS: all', len(volumes), 'volumes restored into a new stopped project. Validate deployment settings before starting.')


if __name__ == '__main__':
    try:
        main()
    except Exception as error:
        raise SystemExit('Backup/restore refused or failed (' + type(error).__name__ + '). No credential output.') from None
