import os, tarfile, shutil, subprocess, json, re, time
from pathlib import Path
BACKUP_ROOT=Path(os.environ.get('SCPANEL_BACKUP_ROOT','/var/backups/scpanel'))
class BackupError(RuntimeError): pass
def _safe_name(s): return re.sub(r'[^A-Za-z0-9_.-]+','_',str(s))[:120]
def _mkdir(): BACKUP_ROOT.mkdir(parents=True,exist_ok=True); os.chmod(BACKUP_ROOT,0o700)
def _add(tf,path,arc):
 p=Path(path)
 if p.exists(): tf.add(str(p),arcname=arc,recursive=True)
def _dump(name,dest):
 d=shutil.which('mariadb-dump') or shutil.which('mysqldump')
 if not d: raise BackupError('MariaDB/MySQL dump utility is not installed.')
 with open(dest,'wb') as f:
  p=subprocess.run([d,'--single-transaction','--routines','--events','--triggers',name],stdout=f,stderr=subprocess.PIPE,timeout=900)
 if p.returncode: raise BackupError(p.stderr.decode(errors='replace')[-1500:])
def create(kind='full',account_id=None,website_id=None,database=None,domain=None):
 _mkdir(); stamp=time.strftime('%Y%m%d-%H%M%S'); out=BACKUP_ROOT/f'{stamp}-{_safe_name(kind)}-{_safe_name(domain or account_id or kind)}.tar.gz'
 from app.app import db
 c=db()
 try:
  with tarfile.open(out,'w:gz') as tf:
   if kind=='full':
    for p in ['/etc/scpanel','/var/lib/scpanel','/etc/nginx','/etc/apache2','/etc/postfix','/etc/dovecot','/etc/bind','/etc/vsftpd','/var/mail/vhosts']: _add(tf,p,'payload'+p)
    for r in c.execute('SELECT name FROM database_inventory').fetchall():
     tmp=BACKUP_ROOT/f'.{stamp}-{_safe_name(r[0])}.sql'
     try: _dump(r[0],tmp); tf.add(tmp,arcname=f'databases/{_safe_name(r[0])}.sql')
     except Exception: pass
     tmp.unlink(missing_ok=True)
   elif kind=='account':
    a=c.execute('SELECT * FROM accounts WHERE id=?',(account_id,)).fetchone()
    if not a: raise BackupError('Hosting account not found.')
    _add(tf,a['home_dir'],f'account/{a["username"]}/home')
    meta=dict(a); meta.pop('password',None); tf.addfile(tarfile.TarInfo('account/metadata.json'))
    # replace empty member with real metadata safely
    m=BACKUP_ROOT/f'.{stamp}.json'; m.write_text(json.dumps(meta,default=str,indent=2)); tf.add(m,arcname='account/metadata.json'); m.unlink()
    for r in c.execute('SELECT name FROM database_inventory WHERE account_id=?',(account_id,)).fetchall():
     tmp=BACKUP_ROOT/f'.{stamp}-{_safe_name(r[0])}.sql'
     try: _dump(r[0],tmp); tf.add(tmp,arcname=f'databases/{_safe_name(r[0])}.sql')
     except Exception: pass
     tmp.unlink(missing_ok=True)
    for r in c.execute('SELECT email FROM mailboxes WHERE account_id=?',(account_id,)).fetchall():
     e=r[0]; dom,loc=e.split('@',1); _add(tf,f'/var/mail/vhosts/{dom}/{loc}',f'mail/{e}')
   elif kind=='website':
    w=c.execute('SELECT * FROM websites WHERE id=?',(website_id,)).fetchone()
    if not w: raise BackupError('Website not found.')
    _add(tf,w['document_root'],f'website/{w["domain"]}/document_root')
   elif kind=='database':
    tmp=BACKUP_ROOT/f'.{stamp}-{_safe_name(database)}.sql'; _dump(database,tmp); tf.add(tmp,arcname=f'database/{_safe_name(database)}.sql'); tmp.unlink()
   elif kind=='email': _add(tf,f'/var/mail/vhosts/{domain}',f'email/{domain}')
   elif kind=='config':
    for p in ['/etc/scpanel','/etc/nginx','/etc/apache2','/etc/postfix','/etc/dovecot','/etc/bind','/etc/vsftpd']: _add(tf,p,'config'+p)
   else: raise BackupError('Unknown backup type.')
 finally: c.close()
 os.chmod(out,0o600); return out
def list_backups():
 _mkdir(); return [{'name':p.name,'size':p.stat().st_size,'mtime':time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(p.stat().st_mtime)),'path':str(p)} for p in sorted(BACKUP_ROOT.glob('*.tar.gz'),reverse=True)]
def _pick(name):
 if not name or Path(name).name!=name or not name.endswith('.tar.gz'): raise BackupError('Invalid backup name.')
 p=(BACKUP_ROOT/name).resolve()
 if p.parent!=BACKUP_ROOT.resolve() or not p.exists(): raise BackupError('Backup not found.')
 return p
def delete(name): _pick(name).unlink()
def restore(name,component='full',target=None):
 p=_pick(name); staging=BACKUP_ROOT/f'.restore-{os.getpid()}-{int(time.time())}'; staging.mkdir(mode=0o700)
 try:
  with tarfile.open(p,'r:gz') as tf:
   for m in tf.getmembers():
    if m.issym() or m.islnk(): raise BackupError('Symlinks are not allowed in restore archives.')
    d=(staging/m.name).resolve()
    if staging.resolve() not in d.parents: raise BackupError('Unsafe archive path.')
   tf.extractall(staging)
  allowed={'full':['payload','databases'],'config':['config'],'website':['website'],'account':['account','databases','mail'],'database':['database'],'email':['email']}
  if component not in allowed: raise BackupError('Invalid restore component.')
  # Component restore is deliberately staged; DB SQL restore is validated but not auto-executed here.
  if component in ('website','account') and target:
   src=staging/allowed[component][0]
   Path(target).mkdir(parents=True,exist_ok=True)
   for item in src.rglob('*'):
    rel=item.relative_to(src); dst=Path(target)/rel
    if item.is_dir(): dst.mkdir(parents=True,exist_ok=True)
    else: dst.parent.mkdir(parents=True,exist_ok=True); shutil.copy2(item,dst)
  return True
 finally: shutil.rmtree(staging,ignore_errors=True)
def apply_retention(days=30):
 cutoff=time.time()-max(1,int(days))*86400; removed=[]
 for r in list_backups():
  if Path(r['path']).stat().st_mtime<cutoff:
   try: delete(r['name']); removed.append(r['name'])
   except Exception: pass
 return removed
