import os, json, hashlib, re, shutil, tarfile, tempfile, subprocess, time, urllib.request
from pathlib import Path
APP=Path('/opt/scpanel'); CFG=Path('/etc/scpanel'); DATA=Path('/var/lib/scpanel'); ROOT=DATA/'updates'; STATE=ROOT/'state.json'
class UpdateError(RuntimeError): pass

def _version(v):
    if not re.fullmatch(r'\d+\.\d+\.\d+(?:P\d+)?', str(v or '')): raise UpdateError('Invalid version.')
    return str(v)
def _parts(v): return tuple(int(x) for x in re.match(r'^(\d+)\.(\d+)\.(\d+)',v).groups())
def ensure(): ROOT.mkdir(parents=True,exist_ok=True); os.chmod(ROOT,0o700)
def sha256(path):
    h=hashlib.sha256()
    with open(path,'rb') as f:
        for b in iter(lambda:f.read(1024*1024),b''): h.update(b)
    return h.hexdigest()
def _state():
    ensure()
    try:return json.loads(STATE.read_text())
    except:return {}
def _save(s): ensure(); STATE.write_text(json.dumps(s,indent=2)); os.chmod(STATE,0o600)
def installed_version():
    vals=[]
    try: vals.append((APP/'RELEASE_VERSION').read_text().strip())
    except: pass
    try: vals.append(json.loads((CFG/'config.json').read_text()).get('version',''))
    except: pass
    try: vals.append(os.environ.get('SCPANEL_VERSION',''))
    except: pass
    vals=[v for v in vals if re.fullmatch(r'\d+\.\d+\.\d+(?:P\d+)?',v or '')]
    return vals[0] if vals and len(set(vals))==1 else (vals[0] if vals else 'unknown')
def verify_runtime(expected=None):
    checks={}
    try: checks['package_file']= (APP/'RELEASE_VERSION').read_text().strip()
    except: checks['package_file']=''
    try: checks['config']=json.loads((CFG/'config.json').read_text()).get('version','')
    except: checks['config']=''
    checks['environment']=os.environ.get('SCPANEL_VERSION','')
    expected=expected or checks['package_file']
    checks['all_match']=bool(expected and checks['package_file']==expected and checks['config']==expected and checks['environment'] in ('',expected))
    return checks
def fetch_manifest(url=None):
    url=url or os.environ.get('SCPANEL_UPDATE_MANIFEST','https://mirror.sikon.com.bd/scpanel/manifest.json')
    try:
        with urllib.request.urlopen(url,timeout=15) as r: data=json.load(r)
    except Exception as e: raise UpdateError(f'Update check failed: {e}')
    if not isinstance(data,dict): raise UpdateError('Invalid update manifest.')
    current=installed_version(); data['current_version']=current
    versions=data.get('versions') or ([data] if data.get('version') else [])
    out=[]
    for x in versions:
        try:
            v=_version(x.get('version')); u=x.get('url'); h=x.get('sha256','').lower()
            if not u or not re.fullmatch(r'[0-9a-f]{64}',h): continue
            out.append({'version':v,'url':u,'sha256':h,'changelog':x.get('changelog','')})
        except: continue
    out.sort(key=lambda x:_parts(x['version']),reverse=True); data['versions']=out
    data['latest']=out[0] if out else None
    return data
def download(item):
    ensure(); v=_version(item['version']); dest=ROOT/f'scpanel-v{v}.tar.gz'
    with urllib.request.urlopen(item['url'],timeout=30) as r, open(dest,'wb') as f:
        shutil.copyfileobj(r,f)
    got=sha256(dest)
    if not hmac_compare(got,item['sha256']): dest.unlink(missing_ok=True); raise UpdateError('SHA256 verification failed; package rejected.')
    _verify_archive(dest,v)
    s=_state(); s['download']={'version':v,'path':str(dest),'sha256':got}; _save(s); return dest
def hmac_compare(a,b): return len(a)==len(b) and all(x==y for x,y in zip(a,b))
def _verify_archive(path,version):
    with tarfile.open(path,'r:gz') as tf:
        names=tf.getnames(); rel=[n for n in names if n.endswith('/RELEASE_VERSION')]
        if not rel: raise UpdateError('Package has no RELEASE_VERSION.')
        if len(rel)!=1: raise UpdateError('Package contains ambiguous release metadata.')
        for m in tf.getmembers():
            if m.issym() or m.islnk(): raise UpdateError('Package contains a symlink/link and is rejected.')
            if Path(m.name).is_absolute() or '..' in Path(m.name).parts: raise UpdateError('Package contains an unsafe path.')
        data=tf.extractfile(rel[0]).read().decode().strip()
        if data!=version: raise UpdateError(f'Package version mismatch: expected {version}, found {data}.')
        root=rel[0].rsplit('/',1)[0]
        if not all(n==root or n.startswith(root+'/') for n in names): raise UpdateError('Package root is unsafe.')
def prepare_backup():
    from app.providers import backup_engine
    out=backup_engine.create('full'); s=_state(); s['rollback_backup']=str(out); _save(s); return out
def apply(path):
    path=Path(path); s=_state()
    if not path.exists(): raise UpdateError('Downloaded package not found.')
    v=s.get('download',{}).get('version')
    if not v: raise UpdateError('No verified download selected.')
    _verify_archive(path,v)
    current=installed_version()
    if current=='unknown': raise UpdateError('Cannot determine installed version.')
    backup=prepare_backup()
    staging=DATA/f'update-staging-{os.getpid()}-{int(time.time())}'; staging.mkdir(mode=0o700)
    try:
        with tarfile.open(path,'r:gz') as tf: tf.extractall(staging)
        roots=[x for x in staging.iterdir() if x.is_dir()]
        if len(roots)!=1: raise UpdateError('Invalid package layout.')
        newroot=roots[0]
        rel=(newroot/'RELEASE_VERSION').read_text().strip()
        if rel!=v: raise UpdateError('Staged package version mismatch.')
        old=DATA/f'rollback-{current}-{int(time.time())}'
        shutil.copytree(APP,old,symlinks=True)
        # Replace application atomically at directory level where possible.
        incoming=DATA/f'incoming-{v}-{os.getpid()}'; shutil.copytree(newroot,incoming,symlinks=True)
        old_app=DATA/f'app-old-{os.getpid()}'; APP.rename(old_app); incoming.rename(APP); shutil.rmtree(old_app,ignore_errors=True)
        cfgp=CFG/'config.json'
        try:
            c=json.loads(cfgp.read_text()); c['version']=v; cfgp.write_text(json.dumps(c,separators=(',',':')))
        except Exception as e: raise UpdateError(f'Config update failed: {e}')
        env=CFG/'admin.env'; lines=env.read_text().splitlines() if env.exists() else []
        found=False; nl=[]
        for line in lines:
            if line.startswith('SCPANEL_VERSION='): nl.append(f'SCPANEL_VERSION={v}'); found=True
            else:nl.append(line)
        if not found:nl.append(f'SCPANEL_VERSION={v}')
        env.write_text('\n'.join(nl)+'\n'); os.chmod(env,0o600)
        s['rollback_app']=str(old); s['previous_version']=current; s['applied_version']=v; _save(s)
        if shutil.which('systemctl'):
            subprocess.run(['systemctl','daemon-reload'],check=False)
            r=subprocess.run(['systemctl','restart','scpanel-admin','scpanel-user'],capture_output=True,text=True,timeout=60)
            if r.returncode: raise UpdateError('Panel services failed to restart: '+(r.stderr[-1000:] or r.stdout[-1000:]))
        return verify_runtime(v)
    except Exception:
        raise
    finally: shutil.rmtree(staging,ignore_errors=True)
def rollback():
    s=_state(); old=Path(s.get('rollback_app',''))
    if not old.exists(): raise UpdateError('No rollback package is available.')
    current=installed_version(); target=s.get('previous_version')
    incoming=DATA/f'rollback-incoming-{os.getpid()}'; shutil.copytree(old,incoming,symlinks=True)
    old_app=DATA/f'rollback-current-{os.getpid()}'; APP.rename(old_app); incoming.rename(APP); shutil.rmtree(old_app,ignore_errors=True)
    if target:
        try:
            c=json.loads((CFG/'config.json').read_text()); c['version']=target; (CFG/'config.json').write_text(json.dumps(c,separators=(',',':')))
            lines=(CFG/'admin.env').read_text().splitlines(); (CFG/'admin.env').write_text('\n'.join((f'SCPANEL_VERSION={target}' if x.startswith('SCPANEL_VERSION=') else x) for x in lines)+'\n')
        except Exception: pass
    if shutil.which('systemctl'):
        subprocess.run(['systemctl','daemon-reload'],check=False); subprocess.run(['systemctl','restart','scpanel-admin','scpanel-user'],check=False,timeout=60)
    s['rolled_back_to']=target or 'unknown'; _save(s); return verify_runtime(target)
def changelog(path=None):
    if path and Path(path).exists(): return Path(path).read_text(errors='replace')
    p=APP/'CHANGELOG.md'; return p.read_text(errors='replace') if p.exists() else 'No changelog available.'
