"""Real Postfix/Dovecot mail provider for SCpanel.

The provider owns only SCpanel-managed snippets/maps and mailbox data. It never
rewrites unrelated Postfix/Dovecot configuration wholesale.
"""
from __future__ import annotations
import os, re, shutil, subprocess, tempfile
from pathlib import Path

MAIL_ROOT=Path('/var/mail/vhosts')
VMAIL_USER='vmail'; VMAIL_UID=5000; VMAIL_GID=5000
PASSWD_FILE=Path('/etc/dovecot/scpanel-users')
DOMAINS_FILE=Path('/etc/postfix/scpanel_virtual_domains')
MAILBOXES_FILE=Path('/etc/postfix/scpanel_virtual_mailboxes')
ALIASES_FILE=Path('/etc/postfix/scpanel_virtual_aliases')
DKIM_DIR=Path('/etc/opendkim/keys')
DOVECOT_SNIPPET=Path('/etc/dovecot/conf.d/99-scpanel.conf')
OPENDKIM_TABLE=Path('/etc/opendkim/KeyTable')
OPENDKIM_SIGNING=Path('/etc/opendkim/SigningTable')
OPENDKIM_TRUSTED=Path('/etc/opendkim/TrustedHosts')

EMAIL_RE=re.compile(r'^[A-Za-z0-9.!#$%&\'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$')
DOMAIN_RE=re.compile(r'^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$',re.I)
LOCAL_RE=re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$')

class MailProviderError(RuntimeError): pass

def _run(args, timeout=60, 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 installed(): return bool(shutil.which('postfix') and shutil.which('doveadm'))

def stack_status():
    return {'postfix':bool(shutil.which('postfix')),'dovecot':bool(shutil.which('dovecot')),
            'opendkim':bool(shutil.which('opendkim') or shutil.which('opendkim-genkey')),
            'healthy':bool(shutil.which('postfix') and shutil.which('doveadm'))}

def _require():
    if not shutil.which('postfix'): raise MailProviderError('Postfix is not installed. Install Postfix from App Store first.')
    if not shutil.which('doveadm'): raise MailProviderError('Dovecot is not installed. Install Dovecot from App Store first.')

def _email(v):
    if not EMAIL_RE.fullmatch(v or ''): raise MailProviderError('Invalid email address.')
    local,domain=v.rsplit('@',1); return local.lower(),domain.lower()

def _domain(v):
    v=(v or '').strip().lower().rstrip('.')
    if not DOMAIN_RE.fullmatch(v): raise MailProviderError('Invalid mail domain.')
    return v

def _maildir(email):
    local,domain=_email(email); return MAIL_ROOT/domain/local

def _ensure_vmail():
    import pwd, grp
    try: pwd.getpwnam(VMAIL_USER)
    except KeyError:
        if not shutil.which('useradd'): raise MailProviderError('useradd is unavailable; cannot create virtual mail user.')
        rc,out=_run(['useradd','--system','--uid',str(VMAIL_UID),'--user-group','--no-create-home','--shell','/usr/sbin/nologin',VMAIL_USER])
        if rc: raise MailProviderError('Could not create vmail system user: '+out[-800:])
    MAIL_ROOT.mkdir(parents=True,exist_ok=True); os.chown(MAIL_ROOT,VMAIL_UID,VMAIL_GID); os.chmod(MAIL_ROOT,0o750)

def _hash_password(password):
    if not password or len(password)<10: raise MailProviderError('Mailbox password must be at least 10 characters.')
    if not shutil.which('doveadm'): raise MailProviderError('doveadm is required for password hashing.')
    rc,out=_run(['doveadm','pw','-s','CRYPT'],30,input_text=password+'\n')
    if rc or not out.strip(): raise MailProviderError('Could not generate Dovecot password hash.')
    return out.strip().splitlines()[-1].strip()

def _atomic(path,text,mode=0o640,uid=None,gid=None):
    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)
        if uid is not None and gid is not None: os.chown(tmp,uid,gid)
        os.replace(tmp,path)
    finally:
        try: os.unlink(tmp)
        except FileNotFoundError: pass

def _maps(mailboxes,forwarders):
    domains=sorted({_email(x['email'])[1] for x in mailboxes})
    mb=[]; aliases=[]
    for x in mailboxes:
        email=x['email'].lower(); md=_maildir(email); rel=str(md.relative_to(MAIL_ROOT))+'/'
        if x['enabled']: mb.append(f'{email} {rel}')
        for f in forwarders:
            if f['source'].lower()==email and f['enabled']:
                aliases.append(f"{email} {f['destination']}")
    for f in forwarders:
        if f['enabled'] and f['source'].lower() not in {x['email'].lower() for x in mailboxes}:
            aliases.append(f"{f['source'].lower()} {f['destination']}")
    return '\n'.join(d+' OK' for d in domains)+'\n','\n'.join(mb)+'\n','\n'.join(aliases)+'\n'

def _write_passwd(mailboxes):
    lines=[]
    for x in mailboxes:
        if not x['enabled']: continue
        home=str(_maildir(x['email']))
        lines.append(f"{x['email']}:{x['password_hash']}:vmail:vmail::{home}::userdb_quota_rule=*:bytes={int(x['quota_mb'])*1024*1024}B")
    _atomic(PASSWD_FILE,'\n'.join(lines)+'\n',0o640,VMAIL_UID,VMAIL_GID)

def _configure_dovecot():
    text='''# SCpanel-managed virtual mail configuration\nprotocols = imap pop3 lmtp\nmail_location = maildir:%h/Maildir\nauth_mechanisms = plain login\npassdb { driver = passwd-file args = scheme=CRYPT /etc/dovecot/scpanel-users }\nuserdb { driver = static args = uid=vmail gid=vmail home=/var/mail/vhosts/%d/%n }\nservice lmtp { unix_listener /var/spool/postfix/private/dovecot-lmtp { mode = 0600 user = postfix group = postfix } }\nservice auth { unix_listener /var/spool/postfix/private/auth { mode = 0660 user = postfix group = postfix } }\nplugin {\n  quota = maildir:User quota\n  sieve = file:~/sieve;active=~/.dovecot.sieve\n}\n'''
    _atomic(DOVECOT_SNIPPET,text,0o644)

def _postconf(param,value):
    if not shutil.which('postconf'): raise MailProviderError('postconf is unavailable.')
    rc,out=_run(['postconf','-e',f'{param}={value}'],30)
    if rc: raise MailProviderError(f'Postfix configuration failed for {param}: {out[-900:]}')

def _configure_postfix():
    _postconf('virtual_mailbox_domains',f'hash:{DOMAINS_FILE}')
    _postconf('virtual_mailbox_maps',f'hash:{MAILBOXES_FILE}')
    _postconf('virtual_alias_maps',f'hash:{ALIASES_FILE}')
    _postconf('virtual_mailbox_base','/var/mail/vhosts')
    _postconf('virtual_minimum_uid',str(VMAIL_UID))
    _postconf('virtual_uid_maps',f'static:{VMAIL_UID}')
    _postconf('virtual_gid_maps',f'static:{VMAIL_GID}')
    _postconf('virtual_transport','lmtp:unix:private/dovecot-lmtp')
    _postconf('smtpd_sasl_type','dovecot'); _postconf('smtpd_sasl_path','private/auth'); _postconf('smtpd_sasl_auth_enable','yes')

def _map_file(path):
    if not shutil.which('postmap'): raise MailProviderError('postmap is unavailable.')
    rc,out=_run(['postmap',str(path)],30)
    if rc: raise MailProviderError('Postfix map compilation failed: '+out[-900:])

def rebuild(mailboxes,forwarders):
    _require(); _ensure_vmail(); _configure_dovecot(); _configure_postfix()
    d,m,a=_maps(mailboxes,forwarders)
    _atomic(DOMAINS_FILE,d,0o640,0,0); _atomic(MAILBOXES_FILE,m,0o640,0,0); _atomic(ALIASES_FILE,a,0o640,0,0)
    _write_passwd(mailboxes)
    for p in (DOMAINS_FILE,MAILBOXES_FILE,ALIASES_FILE): _map_file(p)
    if shutil.which('dovecot'):
        rc,out=_run(['dovecot','-n'],30)
        if rc: raise MailProviderError('Dovecot configuration validation failed: '+out[-1200:])
    rc,out=_run(['postfix','check'],30)
    if rc: raise MailProviderError('Postfix configuration validation failed: '+out[-1200:])
    for svc in ('dovecot','postfix'):
        if shutil.which('systemctl'):
            rc,out=_run(['systemctl','enable','--now',svc],60)
            if rc: raise MailProviderError(f'{svc} could not be started: {out[-900:]}')
    return {'message':'Postfix/Dovecot mail configuration rebuilt, validated and reloaded.'}

def create_mailbox(email,password,quota_mb,enabled=True):
    _require(); local,domain=_email(email); quota=max(1,min(int(quota_mb),1024000)); md=_maildir(email); _ensure_vmail()
    md.mkdir(parents=True,exist_ok=True); (md/'cur').mkdir(exist_ok=True); (md/'new').mkdir(exist_ok=True); (md/'tmp').mkdir(exist_ok=True)
    os.chown(md,VMAIL_UID,VMAIL_GID)
    for p in (md/'cur',md/'new',md/'tmp'): os.chown(p,VMAIL_UID,VMAIL_GID)
    return {'email':f'{local}@{domain}','maildir':str(md),'password_hash':_hash_password(password),'quota_mb':quota,'enabled':bool(enabled)}

def delete_mailbox(email):
    _require(); md=_maildir(email); shutil.rmtree(md,ignore_errors=False) if md.exists() else None

def change_password(email,password): return _hash_password(password)

def apply_autoresponder(email,enabled,subject,message):
    _require(); md=_maildir(email); md.mkdir(parents=True,exist_ok=True); sieve=md/'sieve'; sieve.mkdir(exist_ok=True)
    script=sieve/'scpanel.sieve'
    if enabled:
        if not subject or not message: raise MailProviderError('Autoresponder subject and message are required.')
        safe_sub=subject.replace('\\','\\\\').replace('"','\\"').replace('\r',' ').replace('\n',' ')
        safe_msg=message.replace('\\','\\\\').replace('"','\\"').replace('\r',' ').replace('\n',' ')
        content=f'require ["vacation"];\nvacation :days 1 :subject "{safe_sub}" text:\n{safe_msg}\n.\n;\n'
        _atomic(script,content,0o600,VMAIL_UID,VMAIL_GID)
        link=md/'.dovecot.sieve'; link.unlink(missing_ok=True); link.symlink_to(script)
        if shutil.which('sievec'): _run(['sievec',str(script)],30)
    else:
        (md/'.dovecot.sieve').unlink(missing_ok=True); script.unlink(missing_ok=True); (script.with_suffix('.sieve.svbin')).unlink(missing_ok=True)
    return {'enabled':bool(enabled),'path':str(script)}

def generate_dkim(domain):
    domain=_domain(domain); tool=shutil.which('opendkim-genkey')
    if not tool: raise MailProviderError('opendkim-genkey is not installed. Install OpenDKIM from App Store first.')
    d=DKIM_DIR/domain; d.mkdir(parents=True,exist_ok=True); os.chown(d,VMAIL_UID,VMAIL_GID) if os.geteuid()==0 else None
    key=d/'scpanel.private'; pub=d/'scpanel.txt'
    if not key.exists() or not pub.exists():
        rc,out=_run([tool,'-b','2048','-d',domain,'-D',str(d),'-s','scpanel','-r'],30)
        if rc: raise MailProviderError('DKIM key generation failed: '+out[-900:])
        generated=d/'scpanel.private'; generated_pub=d/'scpanel.txt'
        if generated.exists() and generated!=key: generated.rename(key)
        if generated_pub.exists() and generated_pub!=pub: generated_pub.rename(pub)
    txt=pub.read_text(errors='replace') if pub.exists() else ''
    m=re.search(r'\"([^\"]+)\"',txt)
    value=''.join(re.findall(r'v=DKIM1.*?;',txt,re.I))
    if not value:
        value=txt.split('IN TXT',1)[-1].strip() if 'IN TXT' in txt else ''
    return {'selector':'scpanel','domain':domain,'private_key':str(key),'dns_record':txt.strip(),'dns_value':value}

def configure_dkim(domain):
    domain=_domain(domain); data=generate_dkim(domain)
    if not shutil.which('opendkim'): raise MailProviderError('OpenDKIM daemon is not installed.')
    DKIM_DIR.mkdir(parents=True,exist_ok=True)
    _atomic(OPENDKIM_TABLE,(OPENDKIM_TABLE.read_text(errors='replace') if OPENDKIM_TABLE.exists() else '') + f"scpanel._domainkey.{domain} {domain}:scpanel:{data['private_key']}\n",0o640)
    _atomic(OPENDKIM_SIGNING,(OPENDKIM_SIGNING.read_text(errors='replace') if OPENDKIM_SIGNING.exists() else '') + f"*@{domain} scpanel._domainkey.{domain}\n",0o640)
    trusted=OPENDKIM_TRUSTED.read_text(errors='replace') if OPENDKIM_TRUSTED.exists() else '127.0.0.1\nlocalhost\n'
    if domain not in trusted: trusted += domain+'\n'
    _atomic(OPENDKIM_TRUSTED,trusted,0o644)
    conf='''Syslog yes\nUMask 002\nCanonicalization relaxed/simple\nMode sv\nSocket local:/run/opendkim/opendkim.sock\nPidFile /run/opendkim/opendkim.pid\nUserID opendkim\nKeyTable /etc/opendkim/KeyTable\nSigningTable refile:/etc/opendkim/SigningTable\nTrustedHosts /etc/opendkim/TrustedHosts\n'''
    _atomic(Path('/etc/opendkim.conf'),conf,0o644)
    _postconf('milter_protocol','6'); _postconf('milter_default_action','accept')
    _postconf('smtpd_milters','unix:/run/opendkim/opendkim.sock'); _postconf('non_smtpd_milters','$smtpd_milters')
    if shutil.which('systemctl'):
        rc,out=_run(['systemctl','enable','--now','opendkim'],60)
        if rc: raise MailProviderError('OpenDKIM could not be started: '+out[-900:])
    return data
