#!/usr/bin/env python3
"""Narrow, phased Laravel/aaPanel companion to the existing Node deployment worker."""
import base64
import fcntl
import gzip
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import shutil
import sqlite3
import subprocess
import sys
import tarfile
import time
import urllib.request

PHP_ROOT = Path('/www/wwwroot/app.connectlyvideochat.com')
PHP = '/www/server/php/83/bin/php'
PANEL_DB = Path('/www/server/panel/data/default.db')
TASKS = {
    2: ('1c296f842d03e480559a36897aebb3eb', 'btpython /www/server/panel/script/restart_services.py mysql'),
    4: ('8f6c0a16fd2c06d17bbb5b1fe4ce7241', 'btpython /www/server/panel/script/restart_services.py apache'),
}
CLEANUP_TASK = 5
MOBILE_ROUTE_CHECK = r"""
require 'vendor/autoload.php';
$app = require 'bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$expected = ['userLoginGoogle' => 'GET', 'blockUnblockUser' => 'POST', 'listBlockedUsers' => 'GET',
             'user/delete-account' => 'POST', 'user/restore-account' => 'POST'];
$found = [];
foreach ($app->make('router')->getRoutes() as $route) {
    $uri = ltrim($route->uri(), '/');
    if (!isset($expected[$uri])) continue;
    if (!in_array($expected[$uri], $route->methods(), true)) throw new RuntimeException('Wrong mobile HTTP method');
    $class = $route->getControllerClass();
    $method = substr($route->getActionName(), strrpos($route->getActionName(), '@') + 1);
    $reflection = new ReflectionMethod($class, $method);
    if (!$reflection->isPublic()) throw new RuntimeException('Mobile handler is not public');
    $found[$uri] = true;
}
if (count($found) !== count($expected)) throw new RuntimeException('Missing mobile routes');
foreach (['App\\Services\\MobileBlock', 'App\\Services\\MobileDeletion'] as $class) {
    if (!(new ReflectionMethod($class, 'update'))->isPublic()) throw new RuntimeException('Missing mobile service');
}
if (config('account_deletion.expiry_enabled', false)) throw new RuntimeException('Expiry must remain gated');
echo json_encode(['mobile_routes_verified' => count($found), 'mobile_services_verified' => 2, 'expiry_enabled' => false]);
"""


def digest(path):
    with open(path, 'rb') as stream:
        return hashlib.file_digest(stream, 'sha256').hexdigest()


def verify_files(root, manifest, candidate=False):
    for name, item in manifest.items():
        path = root / name
        if path.is_symlink() or root not in path.resolve().parents:
            raise ValueError('Unexpected source path')
        expected = item['candidate_sha256' if candidate else 'baseline_sha256']
        if expected is None:
            if path.exists():
                raise ValueError(f'Unexpected existing source: {name}')
        elif not path.is_file() or digest(path) != expected:
            raise ValueError(f'Source drift: {name}')


def validate_archive(path, manifest):
    with tarfile.open(path) as stream:
        names = [item.name for item in stream.getmembers()]
        if len(names) != len(set(names)) or set(names) != set(manifest):
            raise ValueError('Unexpected Laravel artifact entries')
        for item in stream.getmembers():
            if not item.isfile() or Path(item.name).is_absolute() or '..' in Path(item.name).parts:
                raise ValueError('Unsafe Laravel artifact')
            if hashlib.sha256(stream.extractfile(item).read()).hexdigest() != manifest[item.name]['candidate_sha256']:
                raise ValueError('Laravel artifact source mismatch')


def read_tasks():
    connection = sqlite3.connect(f'file:{PANEL_DB}?mode=ro', uri=True)
    connection.row_factory = sqlite3.Row
    try:
        return {row['id']: dict(row) for row in connection.execute('SELECT * FROM crontab')}
    finally:
        connection.close()


class Cutover:
    def __init__(self, payload):
        self.action = payload['action']
        self.config = payload['config']
        self.metadata = payload['metadata']
        self.release = self.metadata['release']
        if not re.fullmatch('[a-f0-9]{40}', self.release):
            raise ValueError('Invalid release')
        if self.config['app_dir'] != '/www/wwwroot/connectlyvideochat.com/Videocall':
            raise ValueError('Unexpected Node target')
        if self.config['deploy_dir'] != '/www/backup/bondvia-deploy' or PHP_ROOT.is_symlink():
            raise ValueError('Unexpected cutover path')
        self.work = Path(self.config['deploy_dir']) / 'mobile' / self.release
        self.stage = self.work / 'candidate'
        self.state_file = self.work / 'state.json'
        self.state = json.loads(self.state_file.read_text()) if self.state_file.exists() else {'release': self.release}
        self.node = Path(self.config['app_dir'])

    def save(self):
        path = self.state_file.with_suffix('.tmp')
        path.write_text(json.dumps(self.state, indent=2) + '\n')
        path.replace(self.state_file)

    def run(self, args, cwd=None):
        with open(self.work / 'operations.log', 'a') as log:
            subprocess.run(args, cwd=cwd, check=True, stdout=log, stderr=log)

    def artisan(self, *args):
        self.run(['runuser', '-u', self.config['app_user'], '--', PHP, 'artisan', *args], cwd=PHP_ROOT)

    def task_status(self, task_id, enabled):
        before = read_tasks()
        row = before[task_id]
        if bool(row['status']) == enabled:
            return
        code = (
            "import os,sys;os.chdir('/www/server/panel');sys.path.insert(0,'class');"
            "import public,crontab;"
            f"result=crontab.crontab().set_cron_status(public.to_dict_obj({{'id':{task_id}}}));"
            "assert result.get('status') is True"
        )
        self.run(['btpython', '-c', code])
        after = read_tasks()
        if bool(after[task_id]['status']) != enabled:
            raise ValueError('aaPanel task change was not retained')
        if any(before[key]['status'] != after[key]['status'] for key in before if key != task_id):
            raise ValueError('Unrelated task status changed')
        cron = subprocess.check_output(['crontab', '-l'], text=True)
        present = any(row['echo'] in line for line in cron.splitlines() if not line.lstrip().startswith('#'))
        if present != enabled:
            raise ValueError('aaPanel and system crontab disagree')

    def stage_release(self):
        if self.work.exists():
            raise ValueError('Mobile release already staged; inspect its state')
        verify_files(PHP_ROOT, self.metadata['files'])
        archive = Path(self.config['deploy_dir']) / 'incoming' / f'{self.release}-laravel.tar'
        if digest(archive) != self.metadata['sha256']:
            raise ValueError('Laravel upload hash mismatch')
        validate_archive(archive, self.metadata['files'])
        credential_check = r"const fs=require('fs'),dotenv=require('./node_modules/dotenv');const value=dotenv.parse(fs.readFileSync('/www/wwwroot/app.connectlyvideochat.com/.env')).API_KEY;if(!value||! /^[A-Za-z0-9._-]{1,512}$/.test(value))process.exit(1);"
        subprocess.run([str(Path(self.config['node_bin']) / 'node'), '-e', credential_check], cwd=self.node,
                       check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        if shutil.disk_usage(PHP_ROOT).free < 5 * 1024**3:
            raise ValueError('Insufficient space for combined backups and rollback')
        self.stage.mkdir(parents=True, mode=0o700)
        with tarfile.open(archive) as stream:
            stream.extractall(self.stage, filter='data')
        for name in self.metadata['files']:
            self.run([PHP, '-l', str(self.stage / name)])
        self.state['staged'] = True
        self.save()
        print('Laravel candidate staged, hash checked and PHP syntax verified', flush=True)

    def infra(self):
        if not self.state.get('staged') or self.state.get('infra_started'):
            raise ValueError('Infrastructure phase requires a fresh staged release')
        tasks = read_tasks()
        for task_id, (echo, body) in TASKS.items():
            if tasks[task_id]['echo'] != echo or tasks[task_id]['sBody'].strip() != body:
                raise ValueError('Restart task definition changed')
        if tasks[CLEANUP_TASK]['echo'] != '08ef396db1ab6606b2b2eec1ae406be3':
            raise ValueError('Cleanup task changed')
        (self.work / 'tasks-before.json').write_text(json.dumps(tasks, indent=2))
        (self.work / 'crontab-before.txt').write_text(subprocess.check_output(['crontab', '-l'], text=True))
        with sqlite3.connect(PANEL_DB) as source, sqlite3.connect(self.work / 'panel-before.sqlite') as target:
            source.backup(target)
        for task_id in TASKS:
            path = Path('/www/server/cron') / tasks[task_id]['echo']
            shutil.copy2(path, self.work / f'task-{task_id}.sh')
        self.state['infra_backup_checksums'] = {
            name: digest(self.work / name) for name in ['tasks-before.json', 'crontab-before.txt',
                                                       'panel-before.sqlite', 'task-2.sh', 'task-4.sh']
        }
        self.state['infra_started'] = True
        self.state['node_baseline_sha256'] = digest(self.node / 'server.js')
        self.save()
        for task_id in TASKS:
            self.task_status(task_id, False)
        self.state['restart_tasks_disabled'] = True
        self.save()
        print('Verified task backups; exact Apache and MySQL restart tasks disabled through aaPanel', flush=True)

    def hold(self):
        if not self.state.get('restart_tasks_disabled') or self.state.get('held'):
            raise ValueError('Hold requires stable infrastructure and no prior hold')
        verify_files(PHP_ROOT, self.metadata['files'])
        if (PHP_ROOT / 'storage/framework/down').exists() or (PHP_ROOT / 'storage/framework/maintenance.php').exists():
            raise ValueError('Laravel already in maintenance')
        processes = subprocess.check_output(['ps', '-eo', 'args'], text=True).splitlines()
        if any('artisan' in row and any(key in row for key in ['queue:work', 'queue:listen', 'schedule:run', 'schedule:work'])
               for row in processes):
            raise ValueError('Unquiesced Laravel worker or scheduler')
        self.state['cleanup_was_enabled'] = bool(read_tasks()[CLEANUP_TASK]['status'])
        self.task_status(CLEANUP_TASK, False)
        self.state['hold_started'] = True
        self.save()
        self.artisan('down', '--retry=60')
        if not (PHP_ROOT / 'storage/framework/down').is_file():
            raise ValueError('Laravel maintenance did not activate')
        time.sleep(5)
        print('Laravel writes held; creating full application and persistent-file backup', flush=True)
        archive = self.work / 'laravel-before.tar.gz'
        self.run(['tar', '-czf', str(archive), '-C', str(PHP_ROOT), '.'])
        with gzip.open(archive, 'rb') as stream:
            while stream.read(1024 * 1024):
                pass
        with tarfile.open(archive) as stream:
            names = {item.name[2:] if item.name.startswith('./') else item.name for item in stream.getmembers()}
            if not {'.env', 'artisan', 'vendor/autoload.php', 'app/Http/Controllers/Api/HomeApi.php'} <= names:
                raise ValueError('Incomplete Laravel backup')
        originals = self.work / 'originals'
        originals.mkdir(mode=0o700)
        for name, item in self.metadata['files'].items():
            if item['baseline_sha256']:
                path = originals / name
                path.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(PHP_ROOT / name, path)
        shutil.copy2(self.node / '.env', self.work / 'node-env-before')
        original_stat = (self.node / '.env').stat()
        self.state['node_env_metadata'] = {'uid': original_stat.st_uid, 'gid': original_stat.st_gid,
                                          'mode': original_stat.st_mode & 0o777}
        self.state['backup_checksums'] = {'laravel-before.tar.gz': digest(archive),
                                          'node-env-before': digest(self.work / 'node-env-before')}
        self.state['php_env_sha256'] = digest(PHP_ROOT / '.env')
        self.state['held'] = True
        self.save()
        # Copy the existing Laravel service credential privately; never print it or hardcode it in source.
        js = r"""const fs=require('fs'),dotenv=require('./node_modules/dotenv');
const settings=dotenv.parse(fs.readFileSync('/www/wwwroot/app.connectlyvideochat.com/.env'));
const authorization=settings.API_KEY;if(!authorization||! /^[A-Za-z0-9._-]{1,512}$/.test(authorization))throw Error('Missing service authorization');
const path='.env',old=fs.readFileSync(path,'utf8');const updates={NOTIFICATION_SERVICE_AUTHORIZATION:authorization,RELEASE_COMMIT:process.argv[1],MOBILE_REQUIRE_AUTH:'0'};
let lines=old.split('\n').filter(line=>!Object.keys(updates).some(key=>new RegExp('^\\s*(?:export\\s+)?'+key+'\\s*=').test(line)));
lines.push(...Object.entries(updates).map(([key,value])=>key+'='+JSON.stringify(value)));
const temp=path+'.mobile';fs.writeFileSync(temp,lines.join('\n')+'\n',{mode:0o600});fs.chownSync(temp,Number(process.argv[2]),Number(process.argv[3]));fs.renameSync(temp,path);"""
        account = pwd.getpwnam(self.config['app_user'])
        self.run([str(Path(self.config['node_bin']) / 'node'), '-e', js, self.release,
                  str(account.pw_uid), str(account.pw_gid)], cwd=self.node)
        print('Laravel and environment backups verified; Node configuration prepared for guarded deployment', flush=True)

    def snapshot(self):
        if not self.state.get('restart_tasks_disabled') or self.state.get('snapshot_checksums'):
            raise ValueError('Snapshot requires stable infrastructure and no prior snapshot')
        if not (Path(self.config['pm2_home']) / 'rpc.sock').is_socket():
            raise ValueError('Existing root PM2 daemon is unavailable')
        environment = {**os.environ, 'PM2_HOME': self.config['pm2_home'],
                       'PATH': self.config['node_bin'] + ':' + os.environ.get('PATH', '')}
        processes = subprocess.check_output([str(Path(self.config['node_bin']) / 'pm2'), 'jlist'], env=environment, text=True)
        rows = json.loads(processes)
        targets = [row for row in rows if row['name'] == self.config['pm2_app']]
        if len(targets) != 1 or targets[0]['pm2_env']['pm_cwd'] != str(self.node):
            raise ValueError('PM2 snapshot target differs')
        (self.work / 'preflight-pm2.json').write_text(processes)
        print('Creating private preflight database snapshot for disposable restore verification', flush=True)
        self.run([str(Path(self.config['node_bin']) / 'node'), str(self.node / 'scripts/backup-db.js'),
                  str(self.node), str(self.work / 'preflight-database.sql.gz'), self.config['database_dump_bin'],
                  str(self.work / 'preflight-pm2.json'), self.config['pm2_app']])
        with gzip.open(self.work / 'preflight-database.sql.gz', 'rb') as stream:
            while stream.read(1024 * 1024):
                pass
        self.state['snapshot_checksums'] = {
            name: digest(self.work / name) for name in ['preflight-database.sql.gz', 'preflight-pm2.json']
        }
        self.save()
        print('Preflight database snapshot verified; download and restore only in an isolated database', flush=True)

    def node_ready(self):
        backup = Path(self.config['deploy_dir']) / 'backups' / self.release / 'manifest.json'
        if not backup.is_file():
            raise ValueError('Node backup is not available')
        for key in ['health_url', 'public_health_url']:
            with urllib.request.urlopen(self.config[key], timeout=10) as response:
                data = json.load(response)
                if data.get('success') is not True or data.get('commit') != self.release or not data.get('notifications_configured'):
                    raise ValueError('Exact new Node release is not ready')

    def apply(self):
        if not self.state.get('held') or self.state.get('applied'):
            raise ValueError('Apply requires held writers and unapplied Laravel source')
        self.node_ready()
        for name, expected in self.state['backup_checksums'].items():
            if digest(self.work / name) != expected:
                raise ValueError('Coordinated backup checksum changed')
        if self.state.get('apply_started'):
            # Resume verification only when the complete, exact candidate is already present.
            verify_files(PHP_ROOT, self.metadata['files'], candidate=True)
        else:
            verify_files(PHP_ROOT, self.metadata['files'])
            self.state['apply_started'] = True
            self.save()
            for name in self.metadata['files']:
                target = PHP_ROOT / name
                if not target.parent.exists():
                    target.parent.mkdir(parents=True, mode=0o755)
                    shutil.chown(target.parent, user=self.config['app_user'], group=self.config['app_user'])
                    target.parent.chmod(0o755)
                temporary = target.with_name(target.name + '.mobile')
                shutil.copyfile(self.stage / name, temporary)
                if target.exists():
                    stat = target.stat()
                    os.chown(temporary, stat.st_uid, stat.st_gid)
                    os.chmod(temporary, stat.st_mode & 0o777)
                else:
                    shutil.chown(temporary, user=self.config['app_user'], group=self.config['app_user'])
                    temporary.chmod(0o644)
                temporary.replace(target)
        self.artisan('config:clear')
        self.artisan('route:clear')
        self.run(['runuser', '-u', self.config['app_user'], '--', PHP, '-r', MOBILE_ROUTE_CHECK], cwd=PHP_ROOT)
        verify_files(PHP_ROOT, self.metadata['files'], candidate=True)
        if digest(PHP_ROOT / '.env') != self.state['php_env_sha256']:
            raise ValueError('Laravel environment changed unexpectedly')
        self.state['applied'] = True
        self.save()
        print('Exact Laravel candidate applied and routes booted; persistent files and dependencies preserved', flush=True)

    def resume(self):
        if not self.state.get('applied') or self.state.get('resumed'):
            raise ValueError('Resume requires a verified, unreopened cutover')
        self.node_ready()
        verify_files(PHP_ROOT, self.metadata['files'], candidate=True)
        self.artisan('up')
        if (PHP_ROOT / 'storage/framework/down').exists():
            raise ValueError('Laravel remains in maintenance')
        # The overdue-account backlog has not been approved: retain the cleanup task disabled.
        self.state['resumed'] = True
        self.state['resumed_utc'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
        self.save()
        print('Laravel reopened; expiry remains disabled pending overdue-account review', flush=True)

    def recover(self):
        if not self.state.get('hold_started') or self.state.get('resumed'):
            raise ValueError('Recovery requires an incomplete held cutover; inspect a completed release separately')
        if self.state.get('node_baseline_sha256') and digest(self.node / 'server.js') != self.state['node_baseline_sha256']:
            raise ValueError('Restore Node through the guarded rollback before Laravel recovery')
        if self.state.get('apply_started'):
            for name, item in self.metadata['files'].items():
                target = PHP_ROOT / name
                if item['baseline_sha256']:
                    source = self.work / 'originals' / name
                    if digest(source) != item['baseline_sha256']:
                        raise ValueError('Laravel recovery source mismatch')
                    temporary = target.with_name(target.name + '.mobile')
                    stat = target.stat()
                    shutil.copy2(source, temporary)
                    os.chown(temporary, stat.st_uid, stat.st_gid)
                    temporary.replace(target)
                elif target.exists():
                    if digest(target) != item['candidate_sha256']:
                        raise ValueError('New service changed after cutover')
                    target.unlink()
            self.artisan('config:clear')
            self.artisan('route:clear')
        if self.state.get('held'):
            original = self.work / 'node-env-before'
            if digest(original) != self.state['backup_checksums']['node-env-before']:
                raise ValueError('Node environment recovery mismatch')
            stat = (self.node / '.env').stat()
            metadata = self.state.get('node_env_metadata', {'uid': stat.st_uid, 'gid': stat.st_gid, 'mode': stat.st_mode & 0o777})
            temporary = self.node / '.env.mobile'
            shutil.copy2(original, temporary)
            os.chown(temporary, metadata['uid'], metadata['gid'])
            os.chmod(temporary, metadata['mode'])
            temporary.replace(self.node / '.env')
        verify_files(PHP_ROOT, self.metadata['files'])
        if self.state.get('node_baseline_sha256'):
            if not (Path(self.config['pm2_home']) / 'rpc.sock').is_socket():
                raise ValueError('Existing PM2 daemon unavailable during recovery')
            self.run(['env', 'PM2_HOME=' + self.config['pm2_home'],
                      'PATH=' + self.config['node_bin'] + ':' + os.environ.get('PATH', ''),
                      str(Path(self.config['node_bin']) / 'pm2'), 'restart', self.config['pm2_app'], '--update-env'])
        self.artisan('up')
        self.state['recovered'] = True
        self.save()
        print('Previous Laravel source/environment restored; additive schema retained; Node rollback is a separate guarded step', flush=True)

    def execute(self):
        if self.action == 'stage':
            self.stage_release()
        elif self.action == 'status':
            print(json.dumps({**self.state, 'task_status': {key: read_tasks()[key]['status'] for key in [2, 4, 5]}}, indent=2))
        else:
            if not self.state_file.exists():
                raise ValueError('Mobile release not staged')
            getattr(self, self.action)()


if __name__ == '__main__':
    os.umask(0o077)
    payload = json.loads(base64.b64decode(sys.argv[1]))
    try:
        worker = Cutover(payload)
        if worker.action == 'status':
            worker.execute()
        else:
            with open(Path(worker.config['deploy_dir']) / 'deploy.lock', 'a') as lock:
                fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
                worker.execute()
    except (ValueError, OSError, KeyError, subprocess.CalledProcessError) as error:
        print(f'Coordinated phase stopped: {type(error).__name__}: {str(error) if not isinstance(error, subprocess.CalledProcessError) else "operation failed; inspect private log"}', flush=True)
        raise SystemExit(1)
