#!/usr/bin/env python3
"""Local SSH release client. Production mutations require --production."""
import argparse
import base64
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import sys
import tarfile

ROOT = Path(__file__).resolve().parents[1]
ALLOWED = {'controllers', 'middlewares', 'routes', 'utils', 'public', 'services', 'migrations', 'scripts'}


def run(args, **kwargs):
    return subprocess.run(args, check=True, **kwargs)


def digest(path):
    value = hashlib.sha256()
    with open(path, 'rb') as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b''):
            value.update(chunk)
    return value.hexdigest()


def validate_archive(path):
    with tarfile.open(path) as archive:
        names = set()
        for member in archive.getmembers():
            name = Path(member.name)
            if name.is_absolute() or '..' in name.parts or not (member.isfile() or member.isdir()):
                raise ValueError('Release contains an unsafe archive entry')
            parts = name.parts
            if not parts:
                continue
            top = parts[0]
            if any(part in {'node_modules', 'uploads', '.git', '.deploy'} or
                   part.startswith('.env') or part.endswith(('.pem', '_id_ed25519')) for part in parts):
                raise ValueError('Release contains credentials or persistent data')
            if top not in ALLOWED and not (len(parts) == 1 and (top.endswith('.js') or top in {'package.json', 'package-lock.json'})):
                raise ValueError(f'Unexpected release path: {member.name}')
            if member.isfile():
                if re.search(rb'-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----', archive.extractfile(member).read()):
                    raise ValueError('Private key material in release')
                names.add(member.name)
        if not {'package.json', 'package-lock.json', 'server.js'} <= names:
            raise ValueError('Release needs server.js, package.json and package-lock.json')


def prepare():
    if run(['git', 'status', '--porcelain'], cwd=ROOT, capture_output=True, text=True).stdout.strip():
        raise ValueError('Review and commit all intended changes before preparing a release')
    revision = run(['git', 'rev-parse', 'HEAD'], cwd=ROOT, capture_output=True, text=True).stdout.strip()
    run(['npm', 'test'], cwd=ROOT, env={**os.environ, 'BONDVIA_TEST_DATABASE': '0'})
    run([sys.executable, '-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py'], cwd=ROOT)
    files = run(['git', 'ls-tree', '-r', '--name-only', 'HEAD'], cwd=ROOT, capture_output=True, text=True).stdout.splitlines()
    selected = [name for name in files if name.split('/')[0] in ALLOWED or
                ('/' not in name and (name.endswith('.js') or name in {'package.json', 'package-lock.json'}))]
    directory = ROOT / '.deploy' / 'releases'
    directory.mkdir(parents=True, exist_ok=True, mode=0o700)
    path = directory / f'{revision}.tar'
    run(['git', 'archive', '--format=tar', f'--output={path}', 'HEAD', '--', *selected], cwd=ROOT)
    validate_archive(path)
    metadata = {'revision': revision, 'sha256': digest(path), 'archive': str(path)}
    (directory / f'{revision}.json').write_text(json.dumps(metadata, indent=2) + '\n')
    print(json.dumps(metadata, indent=2))


def configuration(path):
    config = json.loads(Path(path).read_text())
    if not re.fullmatch(r'[A-Za-z0-9.-]+', config['host']) or not re.fullmatch(r'[A-Za-z0-9_-]+', config['user']):
        raise ValueError('Invalid SSH host or user')
    if not 1 <= int(config['port']) <= 65535:
        raise ValueError('Invalid SSH port')
    for name in ['key', 'known_hosts']:
        target = Path(config[name]).expanduser().resolve()
        if not target.is_file():
            raise ValueError(f'Missing {name} file')
        config[name] = str(target)
    if Path(config['key']).stat().st_mode & 0o077:
        raise ValueError('Private key permissions must be 600 or stricter')
    return config


def ssh_options(config):
    known_hosts = config['known_hosts'].replace('\\', '\\\\').replace('"', '\\"')
    return ['-F', '/dev/null', '-i', config['key'], '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes',
            '-o', 'IPQoS=none', '-o', 'StrictHostKeyChecking=yes', '-o', f'UserKnownHostsFile="{known_hosts}"',
            '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3']


def remote(config, action, **values):
    payload = base64.b64encode(json.dumps({'config': config, 'action': action, **values}).encode()).decode()
    code = (ROOT / 'scripts' / 'deploy_remote.py').read_text()
    command = f'python3 - {shlex.quote(payload)}'
    return run(['ssh', '-p', str(config['port']), *ssh_options(config),
                f"{config['user']}@{config['host']}", command], input=code, text=True)


def copy(config, source, destination, download=False):
    target = f"{config['user']}@{config['host']}"
    remote_path = f'{target}:{source if download else destination}'
    args = ['scp', '-P', str(config['port']), *ssh_options(config)]
    run([*args, remote_path, str(destination)] if download else [*args, str(source), remote_path])


def fetch_backup(config, release):
    directory = ROOT / '.deploy' / 'backups' / release
    directory.mkdir(parents=True, exist_ok=True, mode=0o700)
    copy(config, f"{config['deploy_dir']}/backups/{release}/manifest.json", directory / 'manifest.json', download=True)
    (directory / 'manifest.json').chmod(0o600)
    manifest = json.loads((directory / 'manifest.json').read_text())
    names = {'app.tar.gz', 'database.sql.gz', 'pm2.json'}
    if (manifest.get('release') != release or manifest.get('app_dir') != config['app_dir']
            or manifest.get('pm2_app') != config['pm2_app'] or set(manifest.get('checksums', {})) != names):
        raise ValueError('Unexpected backup manifest')
    for name in ['app.tar.gz', 'database.sql.gz', 'pm2.json']:
        expected = manifest['checksums'][name]
        target = directory / name
        if target.is_file() and digest(target) == expected:
            continue
        partial = directory / f'.{name}.partial'
        if name == 'app.tar.gz' and shutil.which('rsync'):
            if not partial.exists():
                # A verified previous archive is an efficient delta basis; never modify it.
                previous = sorted(directory.parent.glob('*/app.tar.gz'), key=lambda p: p.stat().st_mtime, reverse=True)
                for candidate in previous:
                    if candidate == target:
                        continue
                    old_manifest = candidate.parent / 'manifest.json'
                    if not old_manifest.is_file():
                        continue
                    old = json.loads(old_manifest.read_text())
                    if (old.get('app_dir') == config['app_dir'] and old.get('pm2_app') == config['pm2_app']
                            and old.get('checksums', {}).get(name) == digest(candidate)):
                        shutil.copyfile(candidate, partial)
                        break
            ssh = shlex.join(['ssh', '-p', str(config['port']), *ssh_options(config)])
            source = f"{config['user']}@{config['host']}:{config['deploy_dir']}/backups/{release}/{name}"
            run(['rsync', '--partial', '--inplace', '--no-whole-file', '--block-size=4096', '-e', ssh, '--', source, str(partial)])
        else:
            copy(config, f"{config['deploy_dir']}/backups/{release}/{name}", partial, download=True)
        partial.chmod(0o600)
        if digest(partial) != expected:
            raise ValueError('Downloaded backup checksum mismatch; partial retained')
        partial.replace(target)
    print(f'Off-server backup verified: {directory}')


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('action', choices=['inspect', 'prepare', 'deploy', 'rollback', 'fetch-backup'])
    parser.add_argument('--config', default=str(ROOT / '.deploy' / 'config.json'))
    parser.add_argument('--release', help='40-character reviewed Git commit')
    parser.add_argument('--production', action='store_true', help='Execute an authorized production change')
    parser.add_argument('--migrate', action='store_true', help='Run reviewed migrations; database rollback is manual')
    args = parser.parse_args()
    os.umask(0o077)
    if args.action == 'prepare':
        prepare()
        return
    config = configuration(args.config)
    if args.action == 'inspect':
        remote(config, 'inspect')
        return
    if not args.release or not re.fullmatch(r'[0-9a-f]{40}', args.release):
        raise ValueError('Specify --release with the reviewed commit SHA')
    if args.action == 'fetch-backup':
        fetch_backup(config, args.release)
        return
    if not args.production:
        raise ValueError('Production mutations require --production and user authorization')
    if args.action == 'rollback':
        remote(config, 'rollback', release=args.release)
        return
    archive = ROOT / '.deploy' / 'releases' / f'{args.release}.tar'
    metadata = json.loads(archive.with_suffix('.json').read_text())
    validate_archive(archive)
    with tarfile.open(archive) as contents:
        if any(Path(member.name).parts[0] == 'migrations' for member in contents.getmembers()) and not args.migrate:
            raise ValueError('Review the migration and explicitly select --migrate before uploading')
    if metadata['revision'] != args.release or metadata['sha256'] != digest(archive):
        raise ValueError('Prepared release integrity check failed')
    remote(config, 'stage', release=args.release)
    copy(config, archive, f"{config['deploy_dir']}/incoming/{args.release}.tar")
    result = None
    try:
        remote(config, 'deploy', release=args.release, checksum=metadata['sha256'], migrate=args.migrate)
    except subprocess.CalledProcessError as error:
        result = error
    try:
        fetch_backup(config, args.release)
    except (subprocess.CalledProcessError, OSError, ValueError):
        print('Off-server backup copy incomplete. Run fetch-backup; remote backups are retained.', file=sys.stderr)
        if result is None:
            raise
    if result:
        raise result


if __name__ == '__main__':
    try:
        main()
    except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
        message = f'Command failed with exit code {error.returncode}' if isinstance(error, subprocess.CalledProcessError) else str(error)
        print(f'Deployment workflow stopped: {message}', file=sys.stderr)
        sys.exit(1)
