#!/usr/bin/env python3
"""Seven-day maintenance, preview by default. Linux/Python 3.7+."""
import argparse
import fcntl
import json
import os
import re
import shutil
import stat
import subprocess
import sys
import time
from pathlib import Path

DAYS = 7
ROOTS = [(Path('/data/apps/foodsafe' + str(i)) / name, name == 'logs')
         for i in (1, 2) for name in ('aiboxfile', 'logs')]
ARCHIVE = re.compile(r'^.+\.log-(\d{8})-(\d{10,})(?:\.gz)?$')

def validate_config(config):
    info = config.lstat()
    if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
        raise RuntimeError('轮转配置须为root所有的普通文件且组/其他用户不可写：' + str(config))

def run_logrotate(command):
    result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
    if result.stdout:
        print(result.stdout, end='')
    if result.stderr:
        print(result.stderr, end='', file=sys.stderr)
    # Older logrotate can ignore unsafe configs yet exit 0 (observed on Kylin).
    diagnostics = result.stdout + '\n' + result.stderr
    if result.returncode != 0 or re.search(r'^\s*error:|\bIgnoring\b', diagnostics, re.I | re.M):
        raise RuntimeError('logrotate报错或忽略配置，停止，不继续清理AI文件')

def validate_root(root):
    if root.is_symlink() or root.resolve() != root or not root.is_dir():
        raise RuntimeError('目录缺失或路径包含符号链接，停止：' + str(root))

def expired(name, st, cutoff, logs):
    if not stat.S_ISREG(st.st_mode):
        return False
    if not logs:
        return st.st_mtime < cutoff
    match = ARCHIVE.fullmatch(name)
    # Only our rotated archives, never an active .log or somebody else's file.
    return bool(match and int(match.group(2)) < cutoff)

def clean(root, logs, now, apply=False):
    validate_root(root)
    device = root.stat().st_dev
    cutoff = now - DAYS * 86400
    report = {'directory': str(root), 'mode': 'apply' if apply else 'preview',
              'eligible_files': 0, 'eligible_bytes': 0, 'deleted_files': 0,
              'changed_skipped': 0, 'errors': 0, 'examples': []}
    def walk_error(error):
        raise error
    for directory, dirs, files, fd in os.fwalk(str(root), follow_symlinks=False, onerror=walk_error):
        # Never cross mount points or follow directory links.
        dirs[:] = [d for d in dirs if stat.S_ISDIR(os.stat(d, dir_fd=fd, follow_symlinks=False).st_mode)
                   and os.stat(d, dir_fd=fd, follow_symlinks=False).st_dev == device]
        for name in files:
            try:
                before = os.stat(name, dir_fd=fd, follow_symlinks=False)
                if before.st_dev != device or not expired(name, before, cutoff, logs):
                    continue
                report['eligible_files'] += 1
                report['eligible_bytes'] += before.st_size
                if len(report['examples']) < 10:
                    report['examples'].append(str(Path(directory) / name))
                if apply:
                    after = os.stat(name, dir_fd=fd, follow_symlinks=False)
                    identity = lambda s: (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns)
                    if identity(before) != identity(after) or not expired(name, after, cutoff, logs):
                        report['changed_skipped'] += 1
                        continue
                    os.unlink(name, dir_fd=fd)
                    report['deleted_files'] += 1
            except FileNotFoundError:
                report['changed_skipped'] += 1
            except OSError as error:
                report['errors'] += 1
                print('文件处理失败：' + str(Path(directory) / name) + ': ' + str(error), file=sys.stderr)
    return report

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--apply', action='store_true', help='轮转日志并实际删除过期文件；不传则只预览')
    args = parser.parse_args()
    if os.geteuid() != 0:
        raise RuntimeError('请以root执行')
    for root, _ in ROOTS:
        validate_root(root)
    rotate = shutil.which('logrotate')
    if not rotate:
        raise RuntimeError('未找到logrotate，请先执行 dnf install -y logrotate')
    config = Path(__file__).resolve().parent / 'foodsafe-logrotate.conf'
    if not config.is_file():
        raise RuntimeError('缺少同目录的foodsafe-logrotate.conf')
    validate_config(config)
    lockfd = os.open('/run/lock/foodsafe-maintenance.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
    with os.fdopen(lockfd, 'w') as lock:
        try:
            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            print('已有维护任务运行，本次跳过'); return 0
        # Validate rotation configuration before deleting any AI files.
        run_logrotate([rotate, '-d', '-s', '/dev/null', str(config)])
        if args.apply:
            state = Path('/var/lib/foodsafe-maintenance')
            if state.is_symlink():
                raise RuntimeError('轮转状态目录不允许为符号链接')
            state.mkdir(mode=0o700, exist_ok=True)
            run_logrotate([rotate, '-s', str(state/'logrotate.status'), str(config)])
        now = time.time()
        results = [clean(root, logs, now, args.apply) for root, logs in ROOTS]
        for item in results:
            print(json.dumps(item, ensure_ascii=False))
        print('实际清理结束' if args.apply else '预览结束：没有删除文件，没有轮转日志')
        return 1 if any(r['errors'] for r in results) else 0

if __name__ == '__main__':
    try:
        sys.exit(main())
    except Exception as error:
        print('维护失败：' + str(error), file=sys.stderr)
        sys.exit(1)
