#!/usr/bin/env python3
"""Toolez 通用文件传输。Python 3.10+；ZIP/TAR/GZIP 无第三方依赖，7z 需 py7zr。

python toolez.py encode archive.zip transfer.txt
python toolez.py decode transfer.txt restored.zip
python toolez.py pack zip archive.zip file1 file2
python toolez.py compact changes.patch compact.json
python toolez.py restore compact.json restored.patch
python toolez.py apply changes.patch --root /path/to/repo          # 只预演
python toolez.py apply changes.patch --root /path/to/repo --write  # 确认写入
"""
import argparse
import hashlib
import json
import re
import subprocess
import tarfile
import zipfile
import zlib
from pathlib import Path, PurePosixPath

VERSION = 'toolez-transfer/1'
LIMIT = 8 * 1024 * 1024

def check(data):
    if not re.fullmatch('[a-y]{7}', data):
        raise ValueError('需要 7 个 a–y 数据字母')
    a = b = 0
    for w, c in enumerate(data, 1):
        n = ord(c) - 97
        x, y, u, v = n % 5, n // 5, w % 5, w // 5
        a, b = (a + u*x + 3*v*y) % 5, (b + u*y + v*x) % 5
    return chr(97 + (2*a+3*b) % 5 + 5*((a+2*b) % 5))

def group_value(g):
    if not re.fullmatch('[a-y]{8}', g) or check(g[:7]) != g[7]:
        raise ValueError('分组校验失败')
    n = 0
    for c in g[:7]:
        n = n * 25 + ord(c) - 97
    if n > 0xffffffff:
        raise ValueError('分组数值越界')
    return n

def crc(s):
    return f'{zlib.crc32(s.encode()):08x}'

def validate_meta(m):
    name = m.get('name', '')
    if (m.get('version') != VERSION or not isinstance(name, str) or not 0 < len(name) <= 255
        or re.search(r'[\x00-\x1f/\\]', name) or type(m.get('size')) is not int
        or not 0 <= m['size'] <= LIMIT or not re.fullmatch('[a-f0-9]{64}', m.get('sha256', ''))):
        raise ValueError('无效的传输信息')
    return m

def encode(data, name):
    m = validate_meta(dict(version=VERSION, name=name, size=len(data), sha256=hashlib.sha256(data).hexdigest()))
    groups = []
    for i in range(0, len(data), 4):
        n, s = int.from_bytes(data[i:i+4].ljust(4, b'\0'), 'big'), ''
        for _ in range(7):
            n, r = divmod(n, 25)
            s = chr(97+r) + s
        groups.append(s + check(s))
    rows = [' '.join(groups[i:i+7]) + ' |' + crc(''.join(groups[i:i+7])) for i in range(0, len(groups), 7)]
    return '# ' + VERSION + '\n# ' + json.dumps(m, ensure_ascii=False, separators=(',', ':')) + '\n' + '\n'.join(rows) + '\n'

def decode(text):
    if len(text) > LIMIT * 4:
        raise ValueError('编码文本过大')
    lines = text.strip().splitlines()
    if len(lines) < 2 or lines[0] != '# ' + VERSION or not lines[1].startswith('# '):
        raise ValueError('不是 Toolez 通用传输文本')
    m, groups = validate_meta(json.loads(lines[1][2:])), []
    for line in lines[2:]:
        if not line.strip():
            continue
        parts = line.split('|')
        if len(parts) != 2 or len(groups) % 7:
            raise ValueError('无效的行结构')
        g = parts[0].split()
        if not 1 <= len(g) <= 7 or crc(''.join(g)) != parts[1].strip():
            raise ValueError('行校验失败')
        groups.extend(g)
    if len(groups) != (m['size']+3)//4:
        raise ValueError('分组数量与文件长度不匹配')
    data = b''.join(group_value(g).to_bytes(4, 'big') for g in groups)
    if any(data[m['size']:]):
        raise ValueError('末尾补零错误')
    data = data[:m['size']]
    if hashlib.sha256(data).hexdigest() != m['sha256']:
        raise ValueError('SHA-256 不匹配')
    return data

def safe_path(name):
    if name == '/dev/null':
        return name
    if name.startswith(('a/', 'b/')):
        name = name[2:]
    p = PurePosixPath(name)
    if not name or p.is_absolute() or '..' in p.parts or '\\' in name or ':' in name or re.search(r'[\x00-\x1f]', name):
        raise ValueError('不安全的补丁路径')
    return name

def compact(text):
    """仅精简 UTF-8、无上下文的文本补丁；不支持的输入必须保留原始补丁。"""
    if re.search(r'(?m)^(GIT binary patch|Binary files |rename |copy |old mode |new mode |(?:new file mode|deleted file mode) (?!100644\b))', text):
        raise ValueError('二进制、重命名、复制或权限修改请使用原始补丁')
    sections = re.split(r'(?m)^diff --git ', text)
    if len(sections) == 1:
        raise ValueError('需要 git diff -U0 生成的补丁')
    files = []
    for section in sections[1:]:
        old = re.search(r'(?m)^--- ([^\n]+)', section)
        new = re.search(r'(?m)^\+\+\+ ([^\n]+)', section)
        if not old or not new:
            raise ValueError('补丁缺少文本文件头，请保留原始补丁')
        if old[1].startswith('"') or new[1].startswith('"'):
            raise ValueError('引用转义路径请使用 git -c core.quotePath=false diff -U0')
        before, after = safe_path(old[1]), safe_path(new[1])
        hunks = []
        blocks = re.split(r'(?m)^@@ ', section)[1:]
        if not blocks:
            raise ValueError('补丁无文本修改，请保留原始补丁')
        for block in blocks:
            header, body = block.split('\n', 1)
            match = re.match(r'-(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@', header)
            if not match:
                raise ValueError('无法解析补丁块')
            start, count, target, added_count = map(int, match.groups(default='1'))
            removed, added, previous = [], [], None
            for line in body.splitlines(keepends=True):
                if line.startswith('-'):
                    removed.append(line[1:]); previous = removed
                elif line.startswith('+'):
                    added.append(line[1:]); previous = added
                elif line.startswith('\\ No newline') and previous:
                    previous[-1] = previous[-1].removesuffix('\n')
                elif line.startswith(' '):
                    raise ValueError('精简模式需要 -U0 无上下文补丁')
                elif line.strip():
                    raise ValueError('无法识别的补丁内容')
            if len(removed) != count or len(added) != added_count:
                raise ValueError('补丁块行数不一致')
            hunks.append([start, count, target, added_count, ''.join(removed), ''.join(added)])
        files.append(dict(old=before, new=after, hunks=hunks))
    return dict(version='toolez-patch/1', files=files)

def restore(obj):
    if obj.get('version') != 'toolez-patch/1' or not isinstance(obj.get('files'), list):
        raise ValueError('不是 Toolez 精简补丁')
    result = []
    for f in obj['files']:
        old, new = safe_path(f['old']), safe_path(f['new'])
        name = new if new != '/dev/null' else old
        if old != '/dev/null' and new != '/dev/null' and old != new:
            raise ValueError('不支持重命名')
        result.append(f'diff --git a/{name} b/{name}\n')
        if old == '/dev/null': result.append('new file mode 100644\n')
        if new == '/dev/null': result.append('deleted file mode 100644\n')
        result.extend(['--- ' + (old if old == '/dev/null' else 'a/'+old) + '\n', '+++ ' + (new if new == '/dev/null' else 'b/'+new) + '\n'])
        for start, count, target, added_count, removed, added in f['hunks']:
            if any(type(n) is not int or n < 0 for n in (start, count, target, added_count)):
                raise ValueError('补丁行号无效')
            if len(removed.splitlines(keepends=True)) != count or len(added.splitlines(keepends=True)) != added_count:
                raise ValueError('补丁块行数不一致')
            result.append(f'@@ -{start},{count} +{target},{added_count} @@\n')
            for prefix, content in [('-', removed), ('+', added)]:
                for line in content.splitlines(keepends=True):
                    result.append(prefix+line)
                    if not line.endswith('\n'): result.append('\n\\ No newline at end of file\n')
    return ''.join(result)

def write_new(path, data):
    with Path(path).open('xb') as output:
        output.write(data)

def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    commands = parser.add_subparsers(dest='command', required=True)
    for cmd in ('encode', 'decode', 'compact', 'restore'):
        p = commands.add_parser(cmd); p.add_argument('input'); p.add_argument('output')
    p = commands.add_parser('pack'); p.add_argument('format', choices=['zip','7z','tar','tar.gz']); p.add_argument('output'); p.add_argument('files', nargs='+')
    p = commands.add_parser('apply'); p.add_argument('input'); p.add_argument('--root', default='.'); p.add_argument('--write', action='store_true')
    args = parser.parse_args()
    if args.command == 'pack':
        paths = [Path(x) for x in args.files]
        if any(not x.is_file() for x in paths) or len({x.name for x in paths}) != len(paths):
            raise ValueError('仅支持名称不重复的普通文件')
        if Path(args.output).exists(): raise FileExistsError('输出文件已存在')
        if args.format == 'zip':
            with zipfile.ZipFile(args.output, 'x', zipfile.ZIP_DEFLATED, compresslevel=9) as z:
                for path in paths: z.write(path, path.name)
        elif args.format == '7z':
            import py7zr
            with py7zr.SevenZipFile(args.output, 'w') as z:
                for path in paths: z.write(path, path.name)
        else:
            with tarfile.open(args.output, 'x:gz' if args.format == 'tar.gz' else 'x') as z:
                for path in paths: z.add(path, arcname=path.name, recursive=False)
        print('已生成', args.output); return
    if args.command == 'apply':
        data = Path(args.input).read_bytes()
        if data.lstrip().startswith(b'{'): data = restore(json.loads(data)).encode('utf-8')
        # Git 自身校验路径、原文和补丁块；禁止 unsafe-paths，默认只预演。
        base = ['git', '-C', str(Path(args.root).resolve()), 'apply', '--unidiff-zero', '--whitespace=nowarn']
        subprocess.run(base + ['--check', '-'], input=data, check=True)
        if args.write:
            subprocess.run(base + ['-'], input=data, check=True)
            print('补丁已应用')
        else: print('预演通过，未修改文件；添加 --write 才会应用')
        return
    path = Path(args.input)
    if args.command == 'encode':
        if path.stat().st_size > LIMIT: raise ValueError('文件超过 8 MiB')
        output = encode(path.read_bytes(), path.name).encode('utf-8')
    elif args.command == 'decode': output = decode(path.read_text(encoding='utf-8-sig'))
    elif args.command == 'compact': output = json.dumps(compact(path.read_bytes().decode('utf-8-sig')), ensure_ascii=False, separators=(',', ':')).encode('utf-8')
    else: output = restore(json.loads(path.read_text(encoding='utf-8-sig'))).encode('utf-8')
    write_new(args.output, output)
    print('已生成', args.output)

if __name__ == '__main__':
    try: main()
    except (ValueError, OSError, KeyError, ImportError, subprocess.CalledProcessError) as exc:
        raise SystemExit(str(exc)) from exc
