#!/usr/bin/env python3
"""Build and transfer jobs using FTP, explicit FTPS or SFTP, never an SSH shell.

Staging can execute a specific uploaded job over authenticated HTTPS. Production
keeps the cron queue. Uploading alone never proves successful execution.
"""
import datetime
import ftplib
import hashlib
import http.client
import io
import json
import os
from pathlib import Path, PurePosixPath
import re
import secrets
import socket
import ssl
import subprocess
import sys
import tarfile
import tempfile
import urllib.error
import urllib.parse
import urllib.request
import zipfile

APP_PATHS = ('app', 'artisan', 'bootstrap', 'composer.json', 'composer.lock',
             'config', 'database', 'public', 'resources', 'routes')
TARGETS = {'staging': 'apidev.appconjugate', 'production': 'api.appconjugate'}
ID_PATTERN = re.compile(r'\d{14}-[a-f0-9]{12}\Z')
STAGING_RUNNER_URL = 'https://apidev.appconjugate.com/_deploy.php'


class TransferError(Exception):
    """Safe diagnostic text authored here, never a raw protocol exception."""


def required(env, name):
    value = env.get(name, '')
    if not value:
        raise ValueError(f'Missing setting: {name}')
    return value


def boolean(env, name, default=False):
    value = env.get(name, str(default).lower())
    if value not in ('true', 'false'):
        raise ValueError(f'{name} must be true or false')
    return value == 'true'


def settings(env):
    target = required(env, 'DEPLOY_ENVIRONMENT')
    if target not in TARGETS:
        raise ValueError('Unknown environment')
    operation = required(env, 'DEPLOY_OPERATION')
    if operation not in ('deploy', 'artisan', 'status', 'install-runner', 'health', 'initialize-key'):
        raise ValueError('Unknown operation')
    if operation == 'initialize-key' and target != 'staging':
        raise ValueError('Key initialization through this workflow is staging-only')
    protocol = required(env, 'OVH_TRANSFER_PROTOCOL')
    if protocol not in ('ftp', 'ftps', 'sftp'):
        raise ValueError('Choose ftp, ftps (explicit TLS), or sftp')
    root = required(env, 'DEPLOY_PATH').rstrip('/')
    if not re.fullmatch(r'/[A-Za-z0-9_./-]+', root) or '..' in PurePosixPath(root).parts:
        raise ValueError('DEPLOY_PATH must be an absolute transfer path without traversal or spaces')
    if PurePosixPath(root).name != TARGETS[target]:
        raise ValueError('DEPLOY_PATH does not match the selected environment')
    host = required(env, 'OVH_TRANSFER_HOST')
    if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]*', host):
        raise ValueError('Use a transfer hostname without a URL prefix')
    port = int(env.get('OVH_TRANSFER_PORT') or (22 if protocol == 'sftp' else 21))
    if not 0 < port < 65536:
        raise ValueError('Invalid transfer port')
    if protocol == 'sftp':
        required(env, 'OVH_TRANSFER_KNOWN_HOSTS')
    execution = env.get('DEPLOY_EXECUTION', 'cron')
    if execution not in ('cron', 'https') or (execution == 'https' and target != 'staging'):
        raise ValueError('HTTPS execution is staging-only; production must keep cron')
    if operation == 'health' and execution != 'https':
        raise ValueError('The health operation is available for staging HTTPS only')
    if execution == 'https':
        if protocol == 'ftp':
            raise ValueError('Staging HTTPS administration requires encrypted SFTP or FTPS uploads')
        if not re.fullmatch(r'[a-f0-9]{64}', required(env, 'STAGING_RUNNER_TOKEN')):
            raise ValueError('STAGING_RUNNER_TOKEN must be 64 lowercase hex characters; generate with openssl rand -hex 32')
    if operation == 'deploy' and not re.fullmatch(r'\d+\.\d+', required(env, 'OVH_PHP_VERSION')):
        raise ValueError('OVH_PHP_VERSION must be major.minor, matching web PHP and cron PHP')
    return dict(target=target, operation=operation, protocol=protocol, root=root, execution=execution,
                host=host, port=port, user=required(env, 'OVH_TRANSFER_USER'),
                password=required(env, 'OVH_TRANSFER_PASSWORD'),
                known_hosts=env.get('OVH_TRANSFER_KNOWN_HOSTS', ''))


def allowed_member(name, vendor=True):
    parts = PurePosixPath(name).parts
    return (bool(parts) and not name.startswith('/') and '\\' not in name
            and all(part not in ('.', '..', '') for part in name.split('/'))
            and parts[0] in APP_PATHS + (('vendor',) if vendor else ())
            and not any(p in ('.env', '.git', '.DS_Store') or p.startswith('.env.') for p in parts)
            and name not in ('public/hot', 'public/deployment-version.txt', 'public/_deploy.php')
            and not name.startswith(('bootstrap/cache/', 'public/storage/', 'public/_deploy.php/'))
            and name not in ('bootstrap/cache', 'public/storage'))


def build(env):
    source = Path(required(env, 'DEPLOY_SOURCE')).resolve()
    destination = Path(required(env, 'DEPLOY_PACKAGE')).resolve()
    revision = subprocess.check_output(['git', '-C', str(source), 'rev-parse', 'HEAD'], text=True).strip()
    php = subprocess.check_output(['php', '-r', 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;'], text=True)
    if php != required(env, 'OVH_PHP_VERSION'):
        raise ValueError('Build PHP must match OVH_PHP_VERSION')
    with tempfile.TemporaryDirectory(prefix='conjugate-build-') as directory:
        stage = Path(directory)
        archive = subprocess.check_output(['git', '-C', str(source), 'archive', 'HEAD', '--', *APP_PATHS])
        with tarfile.open(fileobj=io.BytesIO(archive)) as exported:
            for member in exported:
                name = member.name.rstrip('/')
                if not allowed_member(name, vendor=False):
                    continue
                if not member.isfile() and not member.isdir():
                    raise ValueError('Application archive contains a link or special file')
                target = stage / name
                if member.isdir():
                    target.mkdir(parents=True, exist_ok=True)
                else:
                    target.parent.mkdir(parents=True, exist_ok=True)
                    with exported.extractfile(member) as incoming, target.open('wb') as outgoing:
                        while chunk := incoming.read(1024 * 1024):
                            outgoing.write(chunk)
        # Package discovery belongs on OVH, where absolute paths/.env are correct.
        for command in (
            ['composer', 'install', '--no-dev', '--prefer-dist', '--no-interaction', '--optimize-autoloader', '--no-scripts'],
            ['composer', 'check-platform-reqs', '--no-dev'],
        ):
            subprocess.run(command, cwd=stage, check=True)
        pack(stage, destination, revision, php)
    print(f'Built commit {revision} with PHP {php}. No server environment file included.')


def pack(stage, destination, revision, php):
    if not re.fullmatch(r'[a-f0-9]{40}', revision):
        raise ValueError('Expected a full commit SHA')
    with zipfile.ZipFile(destination, 'w', compression=zipfile.ZIP_DEFLATED) as archive:
        for path in sorted(stage.rglob('*')):
            name = path.relative_to(stage).as_posix()
            if not allowed_member(name) or path.is_dir():
                continue
            # Dereference Composer vendor/bin links, but not links outside the build.
            path.resolve().relative_to(stage.resolve())
            archive.write(path, name)
        archive.writestr('.deployment.json', json.dumps({'revision': revision, 'php': php}))


class Transfer:
    def __init__(self, config):
        self.config = config
        self.ftp = self.ssh = self.sftp = None

    def __enter__(self):
        c = self.config
        try:
            if c['protocol'] == 'sftp':
                import paramiko
                self.ssh = paramiko.SSHClient()
                print('SFTP: checking the configured server host key.')
                try:
                    with tempfile.NamedTemporaryFile(mode='w') as known:
                        known.write(c['known_hosts'] + '\n')
                        known.flush()
                        self.ssh.load_host_keys(known.name)
                    identity = c['host'] if c['port'] == 22 else f"[{c['host']}]:{c['port']}"
                    matched_keys = self.ssh.get_host_keys().lookup(identity)
                except Exception:
                    raise TransferError('Cannot read OVH_TRANSFER_KNOWN_HOSTS. Use a complete, verified OpenSSH known_hosts entry, not a fingerprint, URL or private key.') from None
                if not matched_keys:
                    raise TransferError('OVH_TRANSFER_KNOWN_HOSTS has no server key matching OVH_TRANSFER_HOST and port. A hostname or SHA256 fingerprint alone is not a known_hosts entry.')
                self.ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
                print('SFTP: connecting, verifying the server key and authenticating.')
                try:
                    self.ssh.connect(c['host'], port=c['port'], username=c['user'], password=c['password'],
                                     look_for_keys=False, allow_agent=False, timeout=30, auth_timeout=30, banner_timeout=30)
                except paramiko.BadHostKeyException:
                    raise TransferError('SFTP server host key does not match the trusted key. Stop and verify the hostname and fingerprint with OVH; do not bypass host-key verification.') from None
                except paramiko.AuthenticationException:
                    raise TransferError('SFTP authentication failed. Check the selected transfer username/password and that SFTP is enabled for this account in OVH.') from None
                except (paramiko.SSHException, EOFError):
                    raise TransferError('SFTP connection/SSH handshake failed before opening file transfer. Check the SFTP hostname, port, server availability and compatibility. This does not imply SSH shell access is required.') from None
                except OSError:
                    raise TransferError('Cannot reach the SFTP server. Check its hostname, port, DNS and network access.') from None
                print('SFTP: authenticated; opening the file-transfer subsystem (no shell).')
                try:
                    self.sftp = self.ssh.open_sftp()
                except (paramiko.SSHException, EOFError, OSError):
                    raise TransferError('Authenticated, but the SFTP subsystem could not be opened. Confirm SFTP is enabled for this OVH account; FTP access alone is not sufficient.') from None
                self.sftp.get_channel().settimeout(60)
                print('SFTP: opening the selected environment directory.')
                try:
                    self.sftp.chdir(c['root'])
                except OSError:
                    raise TransferError('SFTP connected, but the environment directory is missing or inaccessible. Copy the full Remote site path from FileZilla while connected using SFTP, not FTP.') from None
                print('SFTP: environment directory ready.')
            else:
                if c['protocol'] == 'ftps':
                    self.ftp = ftplib.FTP_TLS(context=ssl.create_default_context(), timeout=60)
                else:
                    print('Plain FTP selected: credentials/transfers are unencrypted. Prefer SFTP/FTPS if supported.')
                    self.ftp = ftplib.FTP(timeout=60)
                self.ftp.connect(c['host'], c['port'])
                self.ftp.login(c['user'], c['password'])
                if c['protocol'] == 'ftps':
                    self.ftp.prot_p()
                self.ftp.cwd(c['root'])
            return self
        except Exception:
            self.__exit__(None, None, None)
            raise

    def __exit__(self, *_):
        if self.sftp:
            self.sftp.close()
        if self.ssh:
            self.ssh.close()
        if self.ftp:
            self.ftp.close()

    def mkdir(self, name, mode=0o700):
        try:
            if self.sftp:
                self.sftp.mkdir(name, mode=mode)
            else:
                self.ftp.mkd(name)
        except (OSError, ftplib.error_perm):
            # Prove it exists instead of swallowing permissions errors.
            if self.sftp:
                self.sftp.listdir(name)
            else:
                self.ftp.cwd(name)
                self.ftp.cwd(self.config['root'])

    def get(self, name, limit=1024 * 1024):
        data = io.BytesIO()
        def receive(chunk):
            if data.tell() + len(chunk) > limit:
                raise ValueError('Remote metadata exceeds the size limit')
            data.write(chunk)
        try:
            if self.sftp:
                with self.sftp.open(name, 'rb') as incoming:
                    while chunk := incoming.read(65536):
                        receive(chunk)
            else:
                self.ftp.retrbinary('RETR ' + name, receive)
        except FileNotFoundError:
            return None
        except ftplib.error_perm as error:
            if str(error).startswith('550'):
                return None
            raise
        return data.getvalue()

    def put(self, name, stream):
        temporary = name + '.' + secrets.token_hex(6) + '.part'
        if self.sftp:
            self.sftp.putfo(stream, temporary)
            if self.get(name) is None:
                self.sftp.rename(temporary, name)
            else:
                self.sftp.posix_rename(temporary, name)
        else:
            self.ftp.storbinary('STOR ' + temporary, stream, blocksize=262144)
            self.ftp.rename(temporary, name)
        # No delete-and-replace fallback: a failed rename preserves existing files.

    def put_json(self, name, value):
        self.put(name, io.BytesIO(json.dumps(value, sort_keys=True).encode()))


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def https_error_category(error):
    # urllib commonly wraps TLS/socket errors. Inspect types only: reason text,
    # URLs, headers and partial response bodies may contain private information.
    for _ in range(3):
        if not isinstance(error, urllib.error.URLError):
            break
        error = error.reason
    for error_type, category in (
        (ssl.SSLCertVerificationError, 'tls_certificate'),
        (ssl.SSLError, 'tls_error'),
        (socket.gaierror, 'dns_error'),
        (TimeoutError, 'timeout'),
        # RemoteDisconnected is also a ConnectionResetError; keep it distinct.
        (http.client.RemoteDisconnected, 'connection_closed'),
        (ConnectionResetError, 'connection_reset'),
        (ConnectionRefusedError, 'connection_refused'),
        (ConnectionAbortedError, 'connection_aborted'),
        (http.client.IncompleteRead, 'incomplete_response'),
        (http.client.HTTPException, 'http_protocol_error'),
        (json.JSONDecodeError, 'invalid_json'),
        (UnicodeDecodeError, 'invalid_encoding'),
        (OSError, 'network_error'),
        (ValueError, 'invalid_response'),
    ):
        if isinstance(error, error_type):
            return category
    return 'url_error'


def https_failure(action, category, phase, request_id=None):
    # Only fixed labels and a validated, non-secret job ID enter diagnostics.
    safe_action = action if action in ('health', 'status', 'run') else 'unknown'
    if safe_action == 'run':
        identifier = request_id if isinstance(request_id, str) and ID_PATTERN.fullmatch(request_id) else None
        target = 'request ID ' + identifier if identifier else 'the existing request ID'
        guidance = ('Execution may still be running or interrupted. Inspect status using '
                    + target + ' before taking further action.')
    elif safe_action == 'health':
        guidance = 'Health is read-only; no deployment or Artisan job was submitted by this health request.'
    else:
        guidance = 'This request is read-only; it did not execute a job or establish the target job status.'
    return TransferError(f'Staging HTTPS {safe_action} failed [{category}; phase={phase}]. '
                         + guidance + ' No automatic retry was made.')


def https_status_category(code):
    return f'http_status_{code}' if type(code) is int and 100 <= code <= 599 else 'http_status_error'


def https_request(env, action, request_id=None):
    # The staging origin is fixed. No redirects, token in URLs, disabled TLS
    # verification, or automatic POST retries (an HTTP timeout is ambiguous).
    query = {'action': action}
    body = None
    headers = {'X-Conjugate-Deploy-Token': required(env, 'STAGING_RUNNER_TOKEN'),
               'Accept': 'application/json', 'Cache-Control': 'no-cache'}
    if action == 'run':
        body = json.dumps({'id': request_id}).encode()
        headers['Content-Type'] = 'application/json'
    elif request_id:
        query['id'] = request_id
    request = urllib.request.Request(STAGING_RUNNER_URL + '?' + urllib.parse.urlencode(query),
                                     data=body, headers=headers, method='POST' if body else 'GET')
    phase = 'connect'
    try:
        opener = urllib.request.build_opener(NoRedirect(),
            urllib.request.HTTPSHandler(context=ssl.create_default_context()))
        with opener.open(request, timeout=210 if action == 'run' else 30) as response:
            phase = 'response_headers'
            if response.status != 200:
                raise https_failure(action, https_status_category(response.status), phase, request_id)
            if response.headers.get_content_type() != 'application/json':
                raise https_failure(action, 'unexpected_content_type', phase, request_id)
            phase = 'response_body'
            raw = response.read(1024 * 1024 + 1)
            if len(raw) > 1024 * 1024:
                raise https_failure(action, 'response_too_large', phase, request_id)
            phase = 'decode'
            report = json.loads(raw)
            phase = 'validate'
            if not isinstance(report, dict):
                raise https_failure(action, 'invalid_response_shape', phase, request_id)
            if report.get('environment') != 'staging':
                raise https_failure(action, 'environment_mismatch', phase, request_id)
            if action in ('run', 'status') and report.get('id') != request_id:
                raise https_failure(action, 'request_id_mismatch', phase, request_id)
            return report
    except urllib.error.HTTPError as error:
        # Do not echo response HTML, headers or arbitrary server error text.
        error.close()
        raise https_failure(action, https_status_category(error.code), 'response_headers', request_id) from None
    except (urllib.error.URLError, OSError, ValueError, http.client.HTTPException) as error:
        raise https_failure(action, https_error_category(error), phase, request_id) from None


def check_https(env):
    report = https_request(env, 'health')
    if report.get('protocol') != 1 or report.get('status') != 'ready':
        raise TransferError('Staging runner is outdated, busy or blocked. Run health/status and review existing work before submitting more.')
    if not report.get('zip_available') or not report.get('curl_available'):
        raise TransferError('Enable ZIP and curl in the staging web PHP configuration.')
    if env.get('DEPLOY_OPERATION') == 'deploy' and '.'.join(str(report.get('php', '')).split('.')[:2]) != env.get('OVH_PHP_VERSION'):
        raise TransferError('OVH_PHP_VERSION must match the staging web PHP version reported by health.')
    return report


def install_https(transport, env):
    tooling = Path(__file__).parent
    transport.mkdir('runner-public', mode=0o755)
    bridge = (tooling / 'bridge.php').read_bytes()
    for name, content in [('runner-public/_deploy.php', bridge),
                          ('runner-public/.htaccess', b'Options -Indexes\nDirectoryIndex disabled\n')]:
        existing = transport.get(name)
        if existing is not None and existing != content:
            raise TransferError('runner-public contains an unmanaged or different file. Review it before installing the HTTPS bridge.')
    for name in ('http.php', 'bridge.php'):
        with (tooling / name).open('rb') as stream:
            transport.put('.deploy/' + name, stream)
    # Only a one-way digest is stored on OVH; the actual token stays in Secrets.
    digest = hashlib.sha256(required(env, 'STAGING_RUNNER_TOKEN').encode()).hexdigest()
    transport.put('.deploy/http-token.sha256', io.BytesIO((digest + '\n').encode()))
    transport.put('runner-public/.htaccess', io.BytesIO(b'Options -Indexes\nDirectoryIndex disabled\n'))
    transport.put('runner-public/_deploy.php', io.BytesIO(bridge))
    print('Staging HTTPS runner installed. For first setup set the staging web root to apidev.appconjugate/runner-public, then run health.')
    summary(env, 'Staging HTTPS runner uploaded; no cron needed. On first setup point the staging domain at `apidev.appconjugate/runner-public`, then run `health`. After the first deployment switch to `apidev.appconjugate/current/public`. Never expose the environment root.')


def run(transport, config, env):
    operation = config['operation']
    use_https = config['execution'] == 'https'
    if operation == 'health' or (use_https and operation == 'status' and env.get('DEPLOY_REQUEST_ID') == 'runner'):
        report = https_request(env, 'health')
        print(json.dumps(report, indent=2))
        summary(env, f"Staging HTTPS runner: **{report.get('status')}**; PHP `{report.get('php')}`. Health does not execute queued jobs.")
        return 0 if report.get('status') == 'ready' and report.get('protocol') == 1 else 1
    if operation == 'status':
        request_id = env.get('DEPLOY_REQUEST_ID')
        if request_id == 'runner':
            heartbeat = transport.get('.deploy/runner-status.json')
            if heartbeat is None:
                raise ValueError('Runner has not reported yet. Check the OVH cron configuration.')
            report = json.loads(heartbeat)
            if report.get('environment') != config['target']:
                raise ValueError('Runner environment does not match the selected target')
            print(json.dumps(report, indent=2))
            summary(env, 'Runner heartbeat retrieved. Check its timestamp, PHP version and extensions.')
            return 1 if report.get('status') == 'blocked' else 0
        if not request_id:
            latest = transport.get('.deploy/latest.json')
            if latest is None:
                if use_https:
                    report = https_request(env, 'health')
                    print(json.dumps(report, indent=2))
                    summary(env, 'No jobs submitted yet. Staging HTTPS health retrieved; no job was executed.')
                    return 0 if report.get('status') == 'ready' else 1
                heartbeat = transport.get('.deploy/runner-status.json')
                if heartbeat is None:
                    raise ValueError('No requests or runner heartbeat yet. Configure OVH cron and wait for its first run.')
                report = json.loads(heartbeat)
                if report.get('environment') != config['target']:
                    raise ValueError('Runner environment does not match the selected target')
                print(json.dumps(report, indent=2))
                summary(env, 'Runner heartbeat retrieved. Check its timestamp, PHP version and extensions.')
                return 1 if report.get('status') == 'blocked' else 0
            request_id = json.loads(latest)['id']
        if not ID_PATTERN.fullmatch(request_id):
            raise ValueError('Invalid request ID')
        result = transport.get(f'.deploy/results/{request_id}.json')
        if result is not None:
            report = json.loads(result)
            if report['environment'] != config['target'] or report['id'] != request_id:
                raise ValueError('Result does not match the requested environment/job')
            if not boolean(env, 'DEPLOY_SHOW_OUTPUT'):
                report.pop('output', None)
        elif transport.get(f'.deploy/processing/{request_id}.json') is not None:
            report = dict(id=request_id, status='running-or-interrupted', environment=config['target'])
        elif transport.get(f'.deploy/queue/{request_id}.json') is not None:
            report = dict(id=request_id, status='queued', environment=config['target'])
        else:
            raise ValueError('Request not found; check ID, permissions and target')
        print(json.dumps(report, indent=2))
        summary(env, f"Request `{request_id}`: **{report['status']}** on `{config['target']}`.")
        return 1 if report['status'] in ('failed', 'interrupted', 'running-or-interrupted') else 0
    for directory in ('.deploy', '.deploy/incoming', '.deploy/queue', '.deploy/processing', '.deploy/results'):
        transport.mkdir(directory)
    if operation == 'install-runner':
        transport.put('.deploy/.htaccess', io.BytesIO(b'Require all denied\n'))
        with Path(__file__).with_name('worker.php').open('rb') as worker:
            transport.put('.deploy/worker.php', worker)
        if use_https:
            install_https(transport, env)
            return 0
        print('Runner installed. Configure OVH cron for ' + config['root'].lstrip('/') + '/.deploy/worker.php')
        print('The runner is CLI-only and cannot be triggered through a browser.')
        summary(env, 'Runner uploaded. OVH cron still needs to be configured in the hosting panel.')
        return 0
    if transport.get('.deploy/worker.php') is None:
        raise ValueError('Run install-runner and configure HTTPS (staging) or cron (production) before submitting work')
    if use_https:
        check_https(env)
    request_id = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d%H%M%S') + '-' + secrets.token_hex(6)
    print('Request ID: ' + request_id)
    request = dict(id=request_id, environment=config['target'], operation=operation,
                   created_at=int(datetime.datetime.now(datetime.timezone.utc).timestamp()))
    if operation == 'deploy':
        package = Path(required(env, 'DEPLOY_PACKAGE'))
        with zipfile.ZipFile(package) as archive:
            metadata = json.loads(archive.read('.deployment.json'))
        request.update(revision=metadata['revision'], php=metadata['php'],
                       migrate=boolean(env, 'DEPLOY_MIGRATE'), verify_http=boolean(env, 'DEPLOY_VERIFY_HTTP', True))
        with package.open('rb') as stream:
            request['sha256'] = hashlib.file_digest(stream, 'sha256').hexdigest()
            stream.seek(0)
            transport.put(f'.deploy/incoming/{request_id}.zip', stream)
    elif operation == 'artisan':
        arguments = json.loads(env.get('ARTISAN_ARGUMENTS', '["migrate:status"]'))
        if not isinstance(arguments, list) or not arguments or not all(isinstance(a, str) and '\x00' not in a for a in arguments):
            raise ValueError('Artisan arguments must be a nonempty JSON array of strings')
        request['arguments'] = arguments
    # The queue entry becomes visible only after the complete package is uploaded.
    transport.put_json(f'.deploy/queue/{request_id}.json', request)
    transport.put_json('.deploy/latest.json', {'id': request_id})
    if use_https:
        summary(env, f"Staging request ID: `{request_id}`. Uploaded; HTTPS execution requested. If no final result appears, run `status` with this ID. Do not submit a new deployment/migration blindly.")
        print('Uploaded; executing this staging request over HTTPS (no cron).')
        report = https_request(env, 'run', request_id)
        report.pop('output', None)
        print(json.dumps(report, indent=2))
        summary(env, f"Request `{request_id}`: **{report.get('status')}** on staging.")
        return 0 if report.get('status') == 'succeeded' else 1
    print('QUEUED on OVH; not a completed deployment/command. Run status after the next cron execution.')
    summary(env, f"**Queued, awaiting OVH cron.**\n\nEnvironment: `{config['target']}`\n\nRequest ID: `{request_id}`\n\nRun `status` with this ID for the actual result. Cron may take an hour or longer if other requests are queued.")
    return 0


def summary(env, message):
    if env.get('GITHUB_STEP_SUMMARY'):
        with open(env['GITHUB_STEP_SUMMARY'], 'a') as output:
            output.write(message + '\n')


if __name__ == '__main__':
    try:
        if sys.argv[1:] == ['build']:
            build(os.environ)
            sys.exit(0)
        config = settings(os.environ)
        if sys.argv[1:] == ['validate']:
            print('Deployment settings validated.')
            sys.exit(0)
        with Transfer(config) as connection:
            sys.exit(run(connection, config, os.environ))
    except Exception as error:
        # Protocol/library exceptions may contain credentials; do not print them.
        detail = str(error) if isinstance(error, TransferError) or type(error) is ValueError else type(error).__name__
        print('Deployment transfer failed: ' + detail, file=sys.stderr)
        sys.exit(1)
