#!/usr/bin/env python3
"""Decrypt and validate a complete Hanjing migration archive, offline.
Requires: python -m pip install cryptography
The password is requested interactively; it is never a command argument.
"""
import argparse, base64, getpass, hashlib, json, os, re, shutil, sqlite3, struct, tempfile, zipfile
from pathlib import Path, PurePosixPath
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
BASE_TABLES={'workspace','members','products','images','audit','auth_state','backups','import_jobs','import_items','risks'}
EMPTY={'sessions','captcha','admin_sms_challenges','login_limits','migration_jobs','migration_rows'}

def safe_key(value):
    if not isinstance(value,str) or not value or '\\' in value or '\x00' in value: raise ValueError('Invalid file key')
    p=PurePosixPath(value)
    if p.is_absolute() or any(v in ('','.','..') for v in value.split('/')): raise ValueError('Unsafe file key')
    return Path(*p.parts)

def unpack(archive,output,password):
    archive,output=Path(archive),Path(output)
    if output.exists(): raise ValueError('Choose a new output directory; existing files are never overwritten')
    output.parent.mkdir(parents=True,exist_ok=True)
    stage=Path(tempfile.mkdtemp(prefix='.hanjing-unpack-',dir=output.parent));os.chmod(stage,0o700)
    database=sqlite3.connect(stage/'database.sqlite');database.execute('PRAGMA foreign_keys=OFF')
    manifest=None;row_counts={};files={};done_files=[];source_done=False;complete=False;sequence=0;total_bytes=0
    try:
        with archive.open('rb') as reader:
            if reader.read(8)!=b'HJMIG01\n': raise ValueError('Not a Hanjing migration archive')
            length_bytes=reader.read(4)
            if len(length_bytes)!=4: raise ValueError('Incomplete header')
            n=struct.unpack('>I',length_bytes)[0]
            if n>8192: raise ValueError('Invalid header length')
            raw=reader.read(n)
            if len(raw)!=n: raise ValueError('Incomplete header')
            header=json.loads(raw)
            if header.get('format')!='hanjing-encrypted-migration' or header.get('version')!=1 or header.get('iterations')!=210000 or header.get('kdf')!='PBKDF2-SHA256': raise ValueError('Unsupported encryption format')
            salt=base64.b64decode(header['salt'],validate=True)
            if len(salt)!=16 or not password: raise ValueError('Invalid encryption parameters')
            aes=AESGCM(hashlib.pbkdf2_hmac('sha256',password.encode('utf-8'),salt,210000,32));header_hash=hashlib.sha256(raw).digest()
            definitions={}
            while True:
                length_bytes=reader.read(4)
                if not length_bytes: break
                if len(length_bytes)!=4: raise ValueError('Incomplete frame')
                n=struct.unpack('>I',length_bytes)[0]
                if not 32<=n<=32*1024*1024: raise ValueError('Invalid frame size')
                packet=reader.read(n)
                if len(packet)!=n or struct.unpack('>I',packet[:4])[0]!=sequence: raise ValueError('Incomplete or reordered frame')
                plain=aes.decrypt(packet[4:16],packet[16:],header_hash+struct.pack('>I',sequence));sequence+=1
                size=struct.unpack('>I',plain[:4])[0]
                if size>256*1024 or size+4>len(plain): raise ValueError('Invalid frame metadata')
                meta=json.loads(plain[4:4+size]);body=plain[4+size:];kind=meta.get('kind')
                if kind=='manifest':
                    if manifest is not None or sequence!=1: raise ValueError('Repeated manifest')
                    manifest=json.loads(body)
                    if manifest.get('format')!='hanjing-system-migration' or manifest.get('version')!=1 or manifest.get('jobId')!=header.get('jobId'): raise ValueError('Invalid system manifest')
                    active_tables={d['name'] for d in manifest['tables']}
                    if not BASE_TABLES.issubset(active_tables) or not active_tables.issubset(BASE_TABLES|{'exchange_snapshots'}) or {d['name'] for d in manifest['emptyTables']}!=EMPTY: raise ValueError('Unsupported database schema')
                    for definition in manifest['tables']+manifest['emptyTables']:
                        name=definition['name'];columns=definition['columns']
                        if not columns or any(not re.fullmatch('[A-Za-z_][A-Za-z_0-9]*',c) for c in columns): raise ValueError('Invalid columns')
                        if not re.match(r'^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`\[]?'+re.escape(name)+r'["`\]]?\s*\(',definition['createSql'],re.I): raise ValueError('Invalid table statement')
                        database.execute(definition['createSql'])
                        for index in definition['indexSql']:
                            if not re.match(r'^CREATE\s+(?:UNIQUE\s+)?INDEX\s+',index,re.I): raise ValueError('Invalid index statement')
                            database.execute(index)
                        definitions[name]=definition
                    (stage/'tables').mkdir();(stage/'objects').mkdir();row_counts={name:0 for name in active_tables}
                    (stage/'manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2),encoding='utf-8')
                elif not manifest: raise ValueError('Manifest is required first')
                elif kind=='rows':
                    name=meta['table']
                    if name not in active_tables: raise ValueError('Unexpected table')
                    lines=body.decode('utf-8').splitlines()
                    if len(lines)!=meta['count']: raise ValueError('Row page count mismatch')
                    columns=definitions[name]['columns'];quoted=','.join('"'+c+'"' for c in columns);query='INSERT INTO "'+name+'"('+quoted+') VALUES('+','.join('?' for _ in columns)+')'
                    with (stage/'tables'/f'{name}.jsonl').open('a',encoding='utf-8') as out:
                        for line in lines:
                            row=json.loads(line)
                            if set(row)!=set(columns): raise ValueError('Row columns mismatch')
                            database.execute(query,[row[c] for c in columns]);out.write(line+'\n');row_counts[name]+=1
                elif kind in ('file','source'):
                    key=meta['key'];offset=meta['offset'];total=meta['size']
                    if not isinstance(offset,int) or not isinstance(total,int) or offset<0 or total<0 or len(body)>1024*1024 or offset+len(body)>total: raise ValueError('Invalid file segment')
                    relative=safe_key(key)
                    if kind=='source' and key!='source.zip': raise ValueError('Invalid source key')
                    path=stage/'source.zip' if kind=='source' else stage/'objects'/relative
                    identity=(kind,key)
                    if identity not in files:
                        if offset!=0 or path.exists(): raise ValueError('Unexpected file order')
                        path.parent.mkdir(parents=True,exist_ok=True);files[identity]={'path':path,'at':0,'size':total,'hash':hashlib.sha256(),'type':meta['type'],'done':False}
                    item=files[identity]
                    if item['done'] or item['at']!=offset or item['size']!=total: raise ValueError('File offset mismatch')
                    with path.open('ab') as out:out.write(body)
                    item['hash'].update(body);item['at']+=len(body);total_bytes+=len(body)
                    if item['at']==total:
                        item['done']=True
                        if kind=='source':source_done=True
                        else:done_files.append({'key':key,'type':item['type'],'size':total,'sha256':item['hash'].hexdigest()})
                elif kind=='complete':
                    summary=json.loads(body)
                    if complete or files and any(not f['done'] for f in files.values()): raise ValueError('Incomplete objects')
                    if any(row_counts[n]!=manifest['counts'][n] for n in active_tables) or summary['rowCounts']!=row_counts: raise ValueError('Database counts mismatch')
                    if len(done_files)!=manifest['counts']['images']+manifest['counts']['backups'] or summary['fileCount']!=len(done_files) or summary['fileBytes']!=total_bytes or not source_done or not summary['sourceComplete'] or summary['jobId']!=manifest['jobId']: raise ValueError('File counts mismatch')
                    if reader.read(1): raise ValueError('Unexpected trailing bytes')
                    complete=True;break
                else:raise ValueError('Unknown frame kind')
            if not complete:raise ValueError('Export is unfinished. Use the website resume function first')
        owner=database.execute("SELECT m.role,m.enabled,m.password_hash FROM auth_state a JOIN members m ON m.id=a.owner_member_id WHERE a.id='main'").fetchone()
        if not owner or owner[0]!='admin' or owner[1]!=1 or not owner[2]:raise ValueError('Administrator identity is incomplete')
        # Verify exactly the object keys referenced by all image/backup records.
        expected={r[0] for r in database.execute('SELECT id FROM images')}|{r[0] for r in database.execute('SELECT object_key FROM backups')}
        if expected!={r['key'] for r in done_files}:raise ValueError('File inventory does not match database references')
        database.commit()
        with (stage/'database.sql').open('w',encoding='utf-8') as out:
            for line in database.iterdump():out.write(line+'\n')
        with (stage/'objects.jsonl').open('w',encoding='utf-8') as out:
            for row in done_files:out.write(json.dumps(row,ensure_ascii=False)+'\n')
        # Validate and extract the deployable source with bounded, safe paths.
        with zipfile.ZipFile(stage/'source.zip') as source:
            names=set();total=0
            for info in source.infolist():
                relative=safe_key(info.filename.rstrip('/'));total+=info.file_size
                if info.filename in names or info.file_size>32*1024*1024 or total>512*1024*1024 or (info.external_attr>>16)&0o170000==0o120000:raise ValueError('Invalid source archive')
                names.add(info.filename);target=stage/'source'/relative
                if info.is_dir():target.mkdir(parents=True,exist_ok=True);continue
                target.parent.mkdir(parents=True,exist_ok=True)
                with source.open(info) as inp,target.open('wb') as out:shutil.copyfileobj(inp,out,1024*1024)
        database.close();
        with (stage/'database.sqlite').open('rb') as inp: database_digest=hashlib.file_digest(inp,'sha256').hexdigest()
        (stage/'verified.json').write_text(json.dumps({'databaseSha256':database_digest,'manifestSha256':hashlib.sha256((stage/'manifest.json').read_bytes()).hexdigest(),'sourceSha256':hashlib.sha256((stage/'source.zip').read_bytes()).hexdigest(),'complete':True},indent=2),encoding='utf-8')
        for file in stage.rglob('*'):
            if file.is_file(): os.chmod(file,0o600)
        os.rename(stage,output)
        return {'tables':row_counts,'files':len(done_files),'bytes':total_bytes,'output':str(output)}
    except Exception:
        database.close();shutil.rmtree(stage,ignore_errors=True);raise

if __name__=='__main__':
    parser=argparse.ArgumentParser(description='解密、校验并展开瀚境科技全部系统迁移包')
    parser.add_argument('archive');parser.add_argument('--output',required=True);args=parser.parse_args()
    try:
        result=unpack(args.archive,args.output,getpass.getpass('迁移包加密口令：'))
        print('校验成功：'+json.dumps(result,ensure_ascii=False))
        print('账号密码摘要、权限及改密次数已保留。按 source/MIGRATION.md 部署到新的空数据库。')
    except Exception as error:
        print('校验失败，未写入目标目录：'+str(error));raise SystemExit(1)
