"""Small offline/SSH-identical maintenance bundles with three-way, resumable file CAS.

These bundles update tools or editable APP source, never images, credentials or DBs.
Image/native/DB deployments retain the deployment_kit release-specific workflow.
"""
import base64
import hashlib
import json
from pathlib import Path
import re
import zipfile

from .core import relative, sha256

MAX_BYTES = 32*1024**2


def digest(raw):return hashlib.sha256(raw).hexdigest()


def allowed(kind,name):
    relative(name)
    p=Path(name)
    if any(x.startswith('.') or x=='__pycache__' for x in p.parts):return False
    if kind=='TOOLS':return p.suffix=='.py' and (len(p.parts)==1 or p.parts[0] in ('deployment_kit','bridge_tools','contextra_trace'))
    if kind=='APP_SOURCE':return p.parts[0] in ('app','contextra_trace') and p.suffix in ('.py','.tmpl')
    return False


def create(source,baseline,output,*,kind,version,compatibility,paths,changes):
    source=Path(source);baseline=Path(baseline);output=Path(output)
    if output.exists():raise ValueError('Preserve existing bundle')
    if not re.fullmatch(r'[a-zA-Z0-9_.-]{1,80}',version):raise ValueError('Invalid bundle version')
    if not paths or len(paths)>2000 or len(set(paths))!=len(paths):raise ValueError('Invalid file list')
    files={};payload={};total=0
    for name in sorted(paths):
        if not allowed(kind,name):raise ValueError('Unsupported/private file: '+name)
        for root in (source,baseline):
            if any(p.is_symlink() for p in (root/name,*(root/name).parents)):raise ValueError('Symlink source')
        raw=(source/name).read_bytes();total+=len(raw)
        if total>MAX_BYTES:raise ValueError('Small patch size limit')
        if name.endswith('.py'):compile(raw,name,'exec')
        old=baseline/name
        files[name]={'before':sha256(old) if old.exists() else None,'after':digest(raw),'size':len(raw)}
        payload[name]=raw
    m={'schema':1,'kind':kind,'version':version,'compatibility':compatibility,'changes':changes,
       'effect':'tools only' if kind=='TOOLS' else 'editable APP source only; build/deploy separate',
       'restart_services':[],'files':files}
    with zipfile.ZipFile(output,'x',compression=zipfile.ZIP_DEFLATED) as z:
        z.writestr('manifest.json',json.dumps(m,sort_keys=True))
        for name,raw in payload.items():z.writestr('payload/'+name,raw)
    output.chmod(0o600)
    return {'bundle':str(output),'sha256':sha256(output)}


def verify(path,expected):
    path=Path(path)
    if path.is_symlink() or path.stat().st_size>MAX_BYTES or not re.fullmatch('[0-9a-f]{64}',expected) or sha256(path)!=expected:
        raise ValueError('Approved bundle fingerprint mismatch')
    with zipfile.ZipFile(path) as z:
        infos=z.infolist();names=[i.filename for i in infos]
        if len(names)!=len(set(names)) or sum(i.file_size for i in infos)>MAX_BYTES or len(infos)>2001:
            raise ValueError('Duplicate/oversized archive')
        m=json.loads(z.read('manifest.json'))
        if set(m)!={'schema','kind','version','compatibility','changes','effect','restart_services','files'} or m['schema']!=1 or m['kind'] not in ('TOOLS','APP_SOURCE') or m['restart_services']!=[]:
            raise ValueError('Unsupported patch contract')
        if not isinstance(m['files'],dict) or not m['files'] or set(names)!={'manifest.json',*('payload/'+x for x in m['files'])}:
            raise ValueError('Archive inventory mismatch')
        payload={}
        for name,spec in m['files'].items():
            if not allowed(m['kind'],name) or set(spec)!={'before','after','size'}:raise ValueError('Invalid payload target')
            if spec['before'] is not None and not re.fullmatch('[0-9a-f]{64}',spec['before']):raise ValueError('Invalid baseline hash')
            raw=z.read('payload/'+name)
            if len(raw)!=spec['size'] or digest(raw)!=spec['after']:raise ValueError('Payload fingerprint mismatch')
            if name.endswith('.py'):compile(raw,name,'exec')
            payload[name]=raw
    return m,payload


def check_target(root,name):
    relative(name);root=Path(root);target=root/name
    if not root.is_absolute() or any(p.is_symlink() for p in (target,*target.parents)):
        raise ValueError('Patch path/symlink escape')
    return target


def plan(m,root):
    rows=[]
    for name,spec in m['files'].items():
        target=check_target(root,name)
        now=sha256(target) if target.exists() else None
        rows.append({'path':name,'state':'ALREADY_APPLIED' if now==spec['after'] else 'READY' if now==spec['before'] else 'CONFLICT',
                     'current':now,'before':spec['before'],'after':spec['after']})
    return rows


def apply(m,payload,root,journal,write):
    """Validate all targets first, journal before writes; resume never changes the baseline."""
    root=Path(root);journal=Path(journal)
    identity=digest(json.dumps(m,sort_keys=True).encode())
    rows=plan(m,root)
    if any(r['state']=='CONFLICT' for r in rows):raise ValueError('Local edits conflict; no file overwritten')
    if journal.exists():
        record=json.loads(journal.read_text())
        if record['manifest']!=identity or record['root']!=str(root) or record.get('status')=='RESTORED':raise ValueError('Different/completed rollback journal')
    else:
        record={'manifest':identity,'root':str(root),'status':'APPLYING','files':{}}
        for row in rows:
            p=check_target(root,row['path'])
            if row['state']=='ALREADY_APPLIED':continue
            record['files'][row['path']]={'before':base64.b64encode(p.read_bytes()).decode() if p.exists() else None,
                'after':row['after'],'mode':p.stat().st_mode & 0o777 if p.exists() else 0o644}
        write(journal,record)
    for name,spec in record['files'].items():
        target=check_target(root,name)
        now=sha256(target) if target.exists() else None
        before=digest(base64.b64decode(spec['before'])) if spec['before'] is not None else None
        if now not in (before,spec['after']):raise ValueError('Concurrent patch edit preserved')
        if now!=spec['after']:write(target,payload[name],mode=spec['mode'])
    record['status']='APPLIED';write(journal,record)
    return record


def restore(root,journal,write):
    root=Path(root);record=json.loads(Path(journal).read_text())
    if record['root']!=str(root):raise ValueError('Rollback target mismatch')
    for name,spec in record['files'].items():
        p=check_target(root,name);now=sha256(p) if p.exists() else None
        old=digest(base64.b64decode(spec['before'])) if spec['before'] is not None else None
        if now not in (old,spec['after']):raise ValueError('Later edit prevents rollback: '+name)
    for name,spec in record['files'].items():
        p=check_target(root,name)
        if spec['before'] is None:p.unlink(missing_ok=True)
        else:write(p,base64.b64decode(spec['before']),mode=spec['mode'])
    record['status']='RESTORED';write(Path(journal),record)
    return record
