#!/usr/bin/python3
"""Verified, staged Mihomo updates. Never log subscription URLs or credentials."""
import datetime, fcntl, gzip, hashlib, json, os, pathlib, shutil, subprocess
import sys, tempfile, urllib.parse, yaml
sys.path.insert(0, '/usr/local/lib')
from mihomo_local import apply_local_rules
from mihomo_download import download, core_asset_name
from mihomo_dashboard import extract_dashboard

BASE = pathlib.Path('/etc/mihomo')
STATE = pathlib.Path('/var/lib/mihomo-update')
STATE.mkdir(mode=0o700, exist_ok=True)
os.umask(0o077)
mode = sys.argv[1] if len(sys.argv) > 1 else 'data'
check_only = sys.argv[2:] == ['--check']
if mode not in ('data', 'software', 'dashboard') or (len(sys.argv) > 2 and not check_only):
    sys.exit('Usage: mihomo-update data|software|dashboard [--check]')
lock = open('/run/mihomo-update.lock', 'w')
# Queue an update instead of silently losing a daily/weekly run when the guard
# or another updater holds the lock. The systemd service bounds total runtime.
print('Waiting for Mihomo update lock', flush=True)
fcntl.flock(lock, fcntl.LOCK_EX)

def run(args):
    env = dict(os.environ, SAFE_PATHS=str(BASE))
    p = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=180, env=env)
    if p.returncode:
        raise RuntimeError('command failed: ' + pathlib.Path(args[0]).name)
    return p.stdout

def metadata(url, target):
    return download(url, target, validate=lambda path: json.loads(path.read_text()))

def asset(release, name, target):
    item = next(x for x in release['assets'] if x['name'] == name)
    digest = item.get('digest', '')
    if not digest.startswith('sha256:'):
        raise RuntimeError('official asset has no SHA256 digest')
    def validate(path):
        if hashlib.sha256(path.read_bytes()).hexdigest() != digest.split(':', 1)[1]:
            raise RuntimeError('asset SHA256 mismatch')
    download(item['browser_download_url'], target, validate=validate)

def atomic(source, target, mode=0o600):
    tmp = target.with_name(target.name + '.new')
    shutil.copyfile(source, tmp)
    os.chmod(tmp, mode)
    os.replace(tmp, target)

changed = []
report = {'task': mode, 'started': datetime.datetime.now().astimezone().isoformat(), 'phase': 'prepare'}
try:
    with tempfile.TemporaryDirectory(prefix='.update-', dir=BASE) as tmp:
        stage = pathlib.Path(tmp)
        # Current operational data are links for candidate validation, not retained backups.
        for name in ('Country.mmdb', 'geosite.dat'):
            os.link(BASE / name, stage / name)
        cfg = yaml.load((BASE / 'config.yaml').read_text(), Loader=getattr(yaml, 'CSafeLoader', yaml.SafeLoader))
        candidate = stage / 'config.yaml'
        binary = pathlib.Path('/usr/local/bin/mihomo')
        if mode == 'data':
            report['phase'] = 'subscription download and validation'
            def validate_subscription(path):
                source = yaml.load(path.read_text(), Loader=getattr(yaml, 'CSafeLoader', yaml.SafeLoader))
                if not isinstance(source, dict) or not source.get('proxy-groups') or not source.get('rules'):
                    raise RuntimeError('subscription is not a complete Clash/Mihomo profile')
                # Check policy before accepting either route's subscription, without mutating it.
                import copy
                apply_local_rules(copy.deepcopy(source))
                return source
            source = download((BASE / 'source-url').read_text().strip(), stage / 'subscription.yaml',
                              True, validate=validate_subscription)
            for key in ('proxies', 'proxy-groups', 'proxy-providers', 'rule-providers', 'sub-rules'):
                cfg.pop(key, None)
                if key in source:
                    cfg[key] = source[key]
            # Keep the explicitly configured LAN, Tailscale and campus bypass rules.
            bypass = [
                'IP-CIDR,10.0.0.0/8,DIRECT,no-resolve',
                'IP-CIDR,192.168.32.0/24,DIRECT,no-resolve',
                'IP-CIDR,100.64.0.0/10,DIRECT,no-resolve',
                'IP-CIDR6,fc00::/7,DIRECT,no-resolve',
                'IP-CIDR6,fe80::/10,DIRECT,no-resolve',
                'DOMAIN-SUFFIX,tailscale.com,DIRECT', 'DOMAIN-SUFFIX,ts.net,DIRECT']
            cfg['rules'] = bypass + source['rules']
            apply_local_rules(cfg)
            for kind, prefix in [('proxy-providers', 'provider'), ('rule-providers', 'rule')]:
                for i, provider in enumerate(cfg.get(kind, {}).values()):
                    if provider.get('type') == 'http':
                        provider['path'] = './providers/' + prefix + '-' + str(i) + '.yaml'
            report['phase'] = 'geodata download and verification'
            release = metadata('https://api.github.com/repos/MetaCubeX/meta-rules-dat/releases/latest', stage / 'geo-release.json')
            for remote, local in [('geoip.metadb', 'Country.mmdb'), ('geosite.dat', 'geosite.dat')]:
                (stage / local).unlink()
                asset(release, remote, stage / local)
            changed = ['subscription', 'GeoIP', 'GeoSite']
            report['geodata_release'] = release['tag_name']
        else:
            if mode == 'software':
                report['phase'] = 'software download and verification'
                release = metadata('https://api.github.com/repos/MetaCubeX/mihomo/releases/latest', stage / 'core-release.json')
                if release.get('prerelease') or release.get('draft'):
                    raise RuntimeError('refusing prerelease core')
                tag = release['tag_name']
                current = run([str(binary), '-v']).decode().split()
                if tag not in current:
                    archive = stage / 'mihomo.gz'
                    asset(release, core_asset_name(tag), archive)
                    binary = stage / 'mihomo'
                    with gzip.open(archive, 'rb') as inp, open(binary, 'wb') as out:
                        shutil.copyfileobj(inp, out)
                    os.chmod(binary, 0o755)
                    run([str(binary), '-v'])
                    changed.append('core')
                report['core_version'] = tag
            report['phase'] = 'Zashboard download and verification'
            release = metadata('https://api.github.com/repos/Zephyruso/zashboard/releases/latest', stage / 'ui-release.json')
            if release.get('prerelease') or release.get('draft'):
                raise RuntimeError('refusing prerelease dashboard')
            ui_asset = next(x for x in release['assets'] if x['name'] == 'dist.zip')
            digest = ui_asset.get('digest', '')
            if not digest.startswith('sha256:') or len(digest) != 71 or any(c not in '0123456789abcdef' for c in digest[7:]):
                raise RuntimeError('official dashboard asset has no valid SHA256 digest')
            ui_version = {'project': 'Zephyruso/zashboard', 'version': release['tag_name'], 'sha256': digest[7:]}
            installed = json.loads((STATE / 'ui-release.json').read_text()) if (STATE / 'ui-release.json').exists() else {}
            if ui_version != installed or not (BASE / 'ui' / 'index.html').is_file():
                archive = stage / 'ui.zip'
                asset(release, 'dist.zip', archive)
                extract_dashboard(archive, stage / 'ui')
                changed.append('dashboard')
            report.update(dashboard='Zashboard', dashboard_version=release['tag_name'], dashboard_sha256=digest[7:])
        if mode == 'data':
            # Downloads can take minutes: re-read quarantine immediately before activation.
            apply_local_rules(cfg)
        report['phase'] = 'candidate validation'
        candidate.write_text(yaml.dump(cfg, Dumper=getattr(yaml, 'CSafeDumper', yaml.SafeDumper), allow_unicode=True, sort_keys=False))
        run([str(binary), '-t', '-d', str(stage), '-f', str(candidate)])
        if check_only:
            report.update(status='checked', planned_changes=changed, phase='complete')
            print('Mihomo ' + mode + ' check passed; no files activated or services restarted')
        else:
            # All downloads and validation completed before replacing any live content.
            report['phase'] = 'activate'
            if mode == 'data':
                for name in ('Country.mmdb', 'geosite.dat', 'subscription.yaml'):
                    atomic(stage / name, BASE / name)
                atomic(candidate, BASE / 'config.yaml')
            else:
                if 'core' in changed:
                    atomic(binary, pathlib.Path('/usr/local/bin/mihomo'), 0o755)
                if 'dashboard' in changed:
                    # Versioned web root switches atomically. Keep no historical dashboard copies.
                    ui_target = BASE / ('ui-zashboard-' + digest[7:])
                    if ui_target.exists():
                        # An interrupted prior activation may leave an unreferenced build.
                        if (BASE / 'ui').resolve() == ui_target.resolve():
                            raise RuntimeError('active dashboard differs from recorded version; inspect locally')
                        shutil.rmtree(ui_target)
                    shutil.move(str(stage / 'ui'), str(ui_target))
                    link = BASE / 'ui.new'
                    link.symlink_to(ui_target.name)
                    if (BASE / 'ui').is_dir() and not (BASE / 'ui').is_symlink():
                        shutil.rmtree(BASE / 'ui')
                    os.replace(link, BASE / 'ui')
                    for old in BASE.glob('ui-*'):
                        if old.is_dir() and old != ui_target:
                            shutil.rmtree(old)
                    (STATE / 'ui-release.json').write_text(json.dumps(ui_version, indent=2) + '\n')
                    (STATE / 'ui-commit').unlink(missing_ok=True)
            if mode == 'data' or 'core' in changed:
                report['phase'] = 'restart Mihomo'
                run(['systemctl', 'restart', 'mihomo'])
                run(['systemctl', 'is-active', '--quiet', 'mihomo'])
            report.update(status='success', changed=changed, phase='complete')
            print('Mihomo ' + mode + ' update: ' + (', '.join(changed) if changed else 'already current'))
except Exception as exc:
    report.update(status='failed', error_type=type(exc).__name__)
    if isinstance(exc, RuntimeError):
        report['reason'] = str(exc)
    print('Mihomo ' + mode + ' update failed at ' + report['phase'] + ' (' + type(exc).__name__ + '); inspect local status and download logs.', file=sys.stderr)
    sys.exitcode = 1
finally:
    report['finished'] = datetime.datetime.now().astimezone().isoformat()
    (STATE / (mode + ('-check-status.json' if check_only else '-status.json'))).write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
sys.exit(getattr(sys, 'exitcode', 0))
