"""SCpanel FTP/SFTP provider.

Uses real Linux service accounts with nologin/internal-sftp shells.  Every
managed account is chrooted to a directory below its hosting account root.
vsftpd is configured with an explicit allow-list; sshd gets SCpanel-managed
Match blocks for SFTP users.  Disk quotas are applied with setquota when the
hosting filesystem has user quotas enabled.
"""
from __future__ import annotations
import os, re, shutil, subprocess, tempfile, pwd, grp
from pathlib import Path

FTP_CONF=Path('/etc/vsftpd.conf')
FTP_USERS=Path('/etc/vsftpd/scpanel-users')
FTP_DROPIN=Path('/etc/vsftpd/scpanel.conf')
SSHD_DROPIN=Path('/etc/ssh/sshd_config.d/90-scpanel-sftp.conf')
SSHD_KEYS=Path('/etc/ssh/scpanel-sftp-keys')
PASS_MIN=10
USER_RE=re.compile(r'^[a-z][a-z0-9_-]{2,31}$',re.I)
PUBKEY_RE=re.compile(r'^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[^\s]+(?:\s+.*)?$')

class TransferProviderError(RuntimeError): pass

def _run(args, timeout=45, input_text=None):
    try:
        p=subprocess.run(args,input=input_text,text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=timeout,env={**os.environ,'DEBIAN_FRONTEND':'noninteractive'})
        return p.returncode,p.stdout or ''
    except Exception as e:return 1,str(e)

def status():
    return {
        'vsftpd':bool(shutil.which('vsftpd')),
        'sshd':bool(shutil.which('sshd')),
        'sftp':bool(shutil.which('sftp-server') or shutil.which('internal-sftp') or shutil.which('sshd')),
        'quota_tools':bool(shutil.which('setquota') and shutil.which('quota')),
    }

def _require_ftp():
    if not shutil.which('vsftpd') or not shutil.which('useradd'): raise TransferProviderError('vsftpd and useradd are required. Install vsftpd from App Store first.')

def _require_sftp():
    if not shutil.which('sshd') or not shutil.which('useradd'): raise TransferProviderError('OpenSSH server and useradd are required for SFTP.')

def _valid_name(name):
    if not USER_RE.fullmatch(name or ''): raise TransferProviderError('Username must be 3-32 letters, numbers, _ or - and start with a letter.')
    return name

def _valid_root(account):
    root=Path(account.get('home_dir') or '')
    if not root.is_absolute(): raise TransferProviderError('Hosting account has no valid home directory.')
    real=Path(os.path.realpath(root))
    if real == Path('/') or not real.exists(): raise TransferProviderError('Hosting account home directory does not exist.')
    return real

def safe_dir(account, requested):
    root=_valid_root(account)
    value=(requested or '.').strip().replace('\\','/')
    while value.startswith('/'): value=value[1:]
    parts=[x for x in value.split('/') if x not in ('','.')] 
    if any(x=='..' for x in parts): raise TransferProviderError('Directory traversal is not allowed.')
    p=root.joinpath(*parts)
    parent=Path(os.path.realpath(p.parent if not p.exists() else p))
    if not str(parent).startswith(str(root)+os.sep) and parent != root: raise TransferProviderError('Directory must remain inside the hosting account home.')
    if p.exists() and Path(os.path.realpath(p)) != p and not str(Path(os.path.realpath(p))).startswith(str(root)+os.sep): raise TransferProviderError('Directory symlink escape is blocked.')
    return p

def _password(password):
    if not password or len(password)<PASS_MIN: raise TransferProviderError(f'Password must be at least {PASS_MIN} characters.')

def _ensure_system_user(username, home, password, shell='/usr/sbin/nologin'):
    try: pw=pwd.getpwnam(username); exists=True
    except KeyError: pw=None; exists=False
    if not exists:
        rc,out=_run(['useradd','--no-create-home','--home-dir',str(home),'--shell',shell,username])
        if rc: raise TransferProviderError('Could not create transfer user: '+out[-900:])
    rc,out=_run(['usermod','--home',str(home),'--shell',shell,username])
    if rc: raise TransferProviderError('Could not configure transfer user: '+out[-900:])
    _password(password)
    rc,out=_run(['chpasswd'],30,input_text=f'{username}:{password}\n')
    if rc: raise TransferProviderError('Could not set transfer password: '+out[-900:])
    _unlock(username)

def _unlock(username):
    if shutil.which('usermod'): _run(['usermod','-U',username],20)

def disable_user(username):
    if not pwd_exists(username): return
    rc,out=_run(['usermod','-L',username],20)
    if rc: raise TransferProviderError('Could not disable user: '+out[-700:])

def enable_user(username):
    if not pwd_exists(username): raise TransferProviderError('Transfer user does not exist.')
    _unlock(username)

def pwd_exists(username):
    try: pwd.getpwnam(username); return True
    except KeyError:return False

def _remove_user(username):
    if not pwd_exists(username): return
    if shutil.which('userdel'):
        rc,out=_run(['userdel',username],30)
        if rc: raise TransferProviderError('Could not delete transfer user: '+out[-900:])

def _write(path,text,mode=0o640):
    path.parent.mkdir(parents=True,exist_ok=True)
    fd,tmp=tempfile.mkstemp(prefix='.scpanel-',dir=str(path.parent)); os.close(fd)
    try:
        Path(tmp).write_text(text,encoding='utf-8'); os.chmod(tmp,mode); os.replace(tmp,path)
    finally:
        try: os.unlink(tmp)
        except FileNotFoundError: pass

def _ftp_config(users):
    names=[u['system_username'] for u in users if u['enabled']]
    base='''# SCpanel-managed vsftpd settings\nlisten=YES\nlisten_ipv6=NO\nanonymous_enable=NO\nlocal_enable=YES\nwrite_enable=YES\nlocal_umask=022\ndirmessage_enable=YES\nuse_localtime=YES\nxferlog_enable=YES\nconnect_from_port_20=YES\nchroot_local_user=YES\nallow_writeable_chroot=YES\nlocal_root=%h\nuserlist_enable=YES\nuserlist_deny=NO\nuserlist_file=/etc/vsftpd/scpanel-users\npam_service_name=vsftpd\nseccomp_sandbox=NO\n'''
    _write(FTP_DROPIN,base,0o644); _write(FTP_USERS,'\n'.join(names)+'\n',0o640)
    # Do not destroy unrelated distribution settings; append/update SCpanel keys.
    current=FTP_CONF.read_text(encoding='utf-8') if FTP_CONF.exists() else ''
    managed={}
    for line in base.splitlines():
        if '=' in line and not line.startswith('#'):
            k,v=line.split('=',1); managed[k]=v
    lines=[]; seen=set()
    for line in current.splitlines():
        k=line.split('=',1)[0].strip() if '=' in line and not line.lstrip().startswith('#') else ''
        if k in managed:
            if k in seen: continue
            lines.append(f'{k}={managed[k]}'); seen.add(k)
        else: lines.append(line)
    for k,v in managed.items():
        if k not in seen: lines.append(f'{k}={v}')
    _write(FTP_CONF,'\n'.join(lines).rstrip()+'\n',0o644)

def _ssh_config(users):
    SSHD_KEYS.mkdir(parents=True,exist_ok=True); os.chown(SSHD_KEYS,0,0); os.chmod(SSHD_KEYS,0o755)
    lines=['# SCpanel-managed SFTP users']
    for u in users:
        if not u['enabled']: continue
        name=u['system_username']; home=u['sftp_root']
        lines += [f'Match User {name}',f'    ChrootDirectory {home}','    ForceCommand internal-sftp -d /data','    PasswordAuthentication yes','    PubkeyAuthentication yes','    AuthorizedKeysFile /etc/ssh/scpanel-sftp-keys/%u','    X11Forwarding no','    AllowTcpForwarding no','    PermitTunnel no','    GatewayPorts no']
    lines.append('Match all')
    _write(SSHD_DROPIN,'\n'.join(lines)+'\n',0o644)

def _reload(service):
    if not shutil.which('systemctl'): return
    rc,out=_run(['systemctl','reload',service],30)
    if rc:
        rc,out=_run(['systemctl','restart',service],45)
        if rc: raise TransferProviderError(f'{service} reload failed: {out[-1000:]}')

def validate():
    if shutil.which('vsftpd'):
        # vsftpd has no generic -t; launching in foreground with listen disabled is not safe.
        pass
    if shutil.which('sshd'):
        rc,out=_run(['sshd','-t'],30)
        if rc: raise TransferProviderError('sshd configuration validation failed: '+out[-1200:])
    return True

def apply_ftp(users):
    _require_ftp(); _ftp_config(users); validate(); _reload('vsftpd'); return status()

def apply_sftp(users):
    _require_sftp(); _ssh_config(users); validate(); _reload('ssh'); return status()

def create_ftp(account, username, directory, password, quota_mb, enabled=True):
    _require_ftp(); username=_valid_name(username)
    root=_valid_root(account); p=safe_dir(account,directory); existed=p.exists(); p.mkdir(parents=True,exist_ok=True)
    if not str(p).startswith(str(root)+os.sep) and p!=root: raise TransferProviderError('FTP directory must stay inside account home.')
    _ensure_system_user(username,p,password)
    pw=pwd.getpwnam(username)
    if not existed:
        os.chown(p,pw.pw_uid,grp.getgrnam(account.get('system_user') or username).gr_gid if _group_exists(account.get('system_user')) else pw.pw_gid)
    elif shutil.which('setfacl'):
        _run(['setfacl','-m',f'u:{username}:rwx',str(p)],20)
    if not enabled: disable_user(username)
    quota_apply(username,int(quota_mb or 0),p)
    return {'system_username':username,'directory':str(p),'quota_mb':max(0,int(quota_mb or 0)),'enabled':bool(enabled)}

def _group_exists(g):
    try: grp.getgrnam(g); return True
    except KeyError:return False

def create_sftp(account, username, password, quota_mb, public_key=''):
    _require_sftp(); username=_valid_name(username); root=_valid_root(account)
    base=root/'sftp'/username; data=base/'data'
    # sshd requires the chroot path components to be root-owned.
    data.mkdir(parents=True,exist_ok=True); base.mkdir(parents=True,exist_ok=True)
    os.chown(base,0,0); os.chmod(base,0o755)
    _ensure_system_user(username,data,password,shell='/usr/sbin/nologin')
    os.chown(data,pwd.getpwnam(username).pw_uid,pwd.getpwnam(username).pw_gid); os.chmod(data,0o750)
    if public_key: add_key(username,public_key,base)
    quota_apply(username,int(quota_mb or 0),data)
    return {'system_username':username,'sftp_root':str(base),'directory':str(data),'quota_mb':max(0,int(quota_mb or 0))}

def delete(username, root=None):
    _remove_user(username)
    if root:
        p=Path(root)
        if p.exists() and p.is_dir() and str(p).startswith('/home/'):
            shutil.rmtree(p,ignore_errors=True)

def change_password(username,password):
    if not pwd_exists(username): raise TransferProviderError('Transfer user does not exist.')
    _password(password); rc,out=_run(['chpasswd'],30,input_text=f'{username}:{password}\n')
    if rc: raise TransferProviderError('Password change failed: '+out[-900:])
    _unlock(username)

def _key_file(username):
    return SSHD_KEYS/username

def add_key(username,key,base=None):
    key=key.strip()
    if not PUBKEY_RE.fullmatch(key): raise TransferProviderError('Unsupported or invalid SSH public key.')
    SSHD_KEYS.mkdir(parents=True,exist_ok=True); os.chown(SSHD_KEYS,0,0); os.chmod(SSHD_KEYS,0o755)
    f=_key_file(username); old=f.read_text(encoding='utf-8') if f.exists() else ''
    if key not in old.splitlines(): f.write_text((old.rstrip()+'\n' if old.strip() else '')+key+'\n',encoding='utf-8')
    os.chown(f,0,0); os.chmod(f,0o600)

def remove_key(base,key):
    username=Path(base).name if base else ''
    f=_key_file(username)
    if not f.exists(): return
    rows=[x for x in f.read_text(encoding='utf-8').splitlines() if x.strip()!=key.strip()]
    f.write_text(('\n'.join(rows)+'\n') if rows else '',encoding='utf-8'); os.chown(f,0,0); os.chmod(f,0o600)

def quota_apply(username, quota_mb, path):
    quota_mb=max(0,int(quota_mb or 0))
    if quota_mb<=0:return {'enforced':False,'reason':'unlimited'}
    if not shutil.which('setquota'):
        return {'enforced':False,'reason':'setquota is unavailable; filesystem user quotas are not installed.'}
    mount=_mount_for(path)
    if not mount:return {'enforced':False,'reason':'Could not determine filesystem mount.'}
    blocks=quota_mb*1024
    rc,out=_run(['setquota','-u',username,str(blocks),str(blocks),0,0,mount],30)
    if rc: return {'enforced':False,'reason':'Filesystem user quota is not enabled on the target mount: '+out[-500:]}
    return {'enforced':True,'reason':'filesystem user quota applied'}

def _mount_for(path):
    p=str(Path(path).resolve())
    try:
        best='/'
        for line in Path('/proc/mounts').read_text().splitlines():
            parts=line.split()
            if len(parts)<3: continue
            m=parts[1].replace('\\040',' ')
            if p==m or p.startswith(m.rstrip('/')+'/'):
                if len(m)>len(best): best=m
        return best
    except Exception:return '/'

def quota_usage(username):
    if not shutil.which('quota') or not pwd_exists(username): return {'usage_mb':0,'quota_mb':0,'available':False}
    rc,out=_run(['quota','-u',username],20)
    if rc:return {'usage_mb':0,'quota_mb':0,'available':False,'message':out[-500:]}
    return {'raw':out,'available':True}

def change_directory(account, username, directory):
    p=safe_dir(account,directory); p.mkdir(parents=True,exist_ok=True)
    if not pwd_exists(username): raise TransferProviderError('Transfer user does not exist.')
    rc,out=_run(['usermod','--home',str(p),username],20)
    if rc: raise TransferProviderError('Could not change directory: '+out[-800:])
    return str(p)

def usage(path):
    p=Path(path)
    if not p.exists(): return 0
    rc,out=_run(['du','-sb','--',str(p)],30)
    if rc:return 0
    try:return int(out.split()[0])
    except Exception:return 0
