"""VPS-only, audited browser retention: active release plus two rollback builds.

Usage: script plan CURRENT PREVIOUS SECOND; script apply PLAN_SHA256
Does not touch game services, APKs, maps, imported content or unknown staging jobs.
"""
import datetime
import gzip
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import sys

ROOT = Path('/var/www/html/ro-th-online').resolve(strict=True)
BUILD = ROOT / 'public/Build'
AUDIT = os.environ.get('RO_BROWSER_RETENTION_AUDIT', '20260928')
assert re.fullmatch(r'[a-z0-9-]{1,80}', AUDIT)
PLAN = ROOT / f'deploy/browser-retention-plan-{AUDIT}.json'
JOURNAL = ROOT / f'deploy/browser-retention-completed-{AUDIT}.json'

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

def regular(p):
    assert not p.is_symlink() and p.resolve(strict=True) == p and p.is_relative_to(ROOT)
    assert p.is_file()
    s = p.stat()
    return [s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns]

def active():
    paths = [ROOT / 'mobile/index.html', ROOT / 'public/index.html']
    return {str(p.relative_to(ROOT)): sha(p) for p in paths}

def manifest(value):
    matches = []
    for p in (ROOT / 'deploy').glob('*/release.json'):
        data = json.loads(p.read_text(encoding='utf-8-sig'))
        if data.get('release') == 'mobile-' + value and data.get('verification', {}).get('httpsIndexBothUrls'):
            matches.append((data.get('publishedAtUtc', ''), data))
    assert matches, 'No verified manifest for ' + value
    return max(matches, key=lambda pair: pair[0])[1]

def verify_kept(keep):
    for value in keep:
        data = manifest(value)
        for name, expected in data['files'].items():
            if name == 'WebPreview.data':
                continue
            p = BUILD / ('mobile-' + value) / name
            regular(p)
            assert sha(p) == expected['sha256'], str(p)
        digest = hashlib.sha256()
        for i in range(data['chunkCount']):
            p = BUILD / f'WebPreview.data.{value}.part{i:03}.gz'
            regular(p)
            digest.update(gzip.decompress(p.read_bytes()))
        assert digest.hexdigest() == data['dataHash']
        p = BUILD / f'WebPreview.data.{value}.br'
        regular(p)
        assert sha(p) == data['brotliHash']

def no_open(paths):
    names = {str(ROOT / p) for p in paths}
    for proc in Path('/proc').iterdir():
        if not proc.name.isdigit() or proc.name == str(os.getpid()):
            continue
        try:
            for fd in (proc / 'fd').iterdir():
                try:
                    assert os.readlink(fd) not in names, 'Release file is open: ' + str(fd)
                except FileNotFoundError:
                    pass
        except (FileNotFoundError, PermissionError):
            pass

if sys.argv[1] == 'plan':
    keep = sys.argv[2:]
    assert len(keep) == 3 and len(set(keep)) == 3 and all(re.fullmatch('[a-f0-9]{8}', x) for x in keep)
    for relative in active():
        text = (ROOT / relative).read_text()
        assert 'mobile-' + keep[0] in text, 'Both entry pages must use the current build'
        assert all(x in keep for x in re.findall(r'(?:mobile-|WebPreview\.data\.)([a-f0-9]{8})', text))
    verify_kept(keep)
    candidates, retired, directories = [], set(), []
    for p in BUILD.iterdir():
        match = re.fullmatch(r'mobile-([a-f0-9]{8})|WebPreview\.data\.([a-f0-9]{8})\.(?:br|part\d{3}\.gz)', p.name)
        if not match:
            continue
        value = match[1] or match[2]
        if value in keep:
            continue
        retired.add(value)
        if p.is_dir():
            assert not p.is_symlink() and p.resolve(strict=True).parent == BUILD
            directories.append(str(p.relative_to(ROOT)))
            for child in p.iterdir():
                assert child.is_file() and not child.is_symlink(), 'Unexpected release structure'
                candidates.append(child)
        else:
            candidates.append(p)
    # Original owner-browser binaries are obsolete only after its page moves
    # to the same current versioned release. Keep shared templates/fonts/content.
    for name in ['WebPreview.data', 'WebPreview.framework.js', 'WebPreview.loader.js', 'WebPreview.wasm']:
        p = BUILD / name
        if p.exists():
            candidates.append(p)
    # A historical full binary backup belongs to the retired 35069e10 page.
    old_backup = ROOT / 'deploy/mobile-ui-20260927'
    if '35069e10' in retired and old_backup.exists():
        assert 'WebPreview.data.35069e10.br' in (old_backup / 'mobile-index.html').read_text()
        for name in ['WebPreview.data', 'WebPreview.wasm']:
            p = old_backup / name
            if p.exists():
                candidates.append(p)
    records = [dict(path=str(p.relative_to(ROOT)), version=regular(p), bytes=p.stat().st_size) for p in sorted(candidates)]
    no_open([r['path'] for r in records])
    result = dict(keep=keep, retired=sorted(retired), entryHashes=active(), files=records, directories=directories,
                  createdUtc=datetime.datetime.now(datetime.timezone.utc).isoformat())
    assert not PLAN.exists()
    PLAN.write_text(json.dumps(result, indent=2))
    print(json.dumps(dict(planSha256=sha(PLAN), keep=keep, retired=result['retired'], files=len(records), logicalBytes=sum(r['bytes'] for r in records))))
elif sys.argv[1] == 'apply':
    assert sha(PLAN) == sys.argv[2] and not JOURNAL.exists()
    plan = json.loads(PLAN.read_text())
    assert active() == plan['entryHashes'], 'Active pages changed since audit'
    verify_kept(plan['keep'])
    for record in plan['files']:
        assert regular(ROOT / record['path']) == record['version'], 'Candidate changed since audit'
    no_open([r['path'] for r in plan['files']])
    result = dict(keep=plan['keep'], retired=plan['retired'], deleted=[], freeBefore=shutil.disk_usage(ROOT).free)
    JOURNAL.write_text(json.dumps(result, indent=2))
    for record in plan['files']:
        p = ROOT / record['path']
        # Shared chunks are hard links; unlinking retired names preserves retained files.
        assert regular(p)[:4] == record['version'][:4]
        p.unlink()
        result['deleted'].append(record['path'])
        if len(result['deleted']) % 25 == 0:
            JOURNAL.write_text(json.dumps(result, indent=2))
    for relative in plan['directories']:
        p = ROOT / relative
        assert p.resolve(strict=True).parent == BUILD and not p.is_symlink()
        p.rmdir()
    assert active() == plan['entryHashes']
    verify_kept(plan['keep'])
    result.update(freeAfter=shutil.disk_usage(ROOT).free, completedUtc=datetime.datetime.now(datetime.timezone.utc).isoformat(), verifiedRetained=True)
    JOURNAL.write_text(json.dumps(result, indent=2))
    print(json.dumps(result))
else:
    raise SystemExit('Expected plan or apply')
