"""Real OS package/service provider engine for SIKON Cpanel v1.3.0P4.

Designed for low-latency dashboard reads: service discovery is cached and uses
bulk dpkg/systemd snapshots instead of one command per service.
"""
from __future__ import annotations
import json, os, re, shutil, subprocess, time, threading
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

LOG_DIR=Path('/var/log/scpanel/appstore'); LOG_DIR.mkdir(parents=True,exist_ok=True)
CATALOG_TTL=8.0
CACHE_FILE=Path('/var/lib/scpanel/service_inventory.json')
_catalog_cache={'ts':0.0,'data':[]}
_refresh_lock=threading.RLock()

@dataclass(frozen=True)
class AppSpec:
    key:str; name:str; category:str; description:str; packages:tuple[str,...]; services:tuple[str,...]; binary_candidates:tuple[str,...]=(); optional:bool=False

SPECS={
 'nginx':AppSpec('nginx','Nginx','Web Server','High-performance web server.',('nginx',),('nginx',),('nginx',)),
 'apache':AppSpec('apache','Apache HTTP Server','Web Server','Apache web server.',('apache2',),('apache2',),('apache2',)),
 'php-fpm':AppSpec('php-fpm','PHP / PHP-FPM','Runtime','PHP runtime with FPM process manager.',('php-fpm',),('php-fpm',),('php',),True),
 'mariadb':AppSpec('mariadb','MariaDB','Database','MariaDB database server.',('mariadb-server',),('mariadb',),('mariadbd','mysqld')),
 'mysql':AppSpec('mysql','MySQL','Database','MySQL database server.',('mysql-server',),('mysql',),('mysqld','mysql')),
 'phpmyadmin':AppSpec('phpmyadmin','phpMyAdmin','Database','Web-based MariaDB/MySQL database administration.',('phpmyadmin',),(),('phpmyadmin',),True),
 'bind9':AppSpec('bind9','BIND DNS','DNS','Authoritative DNS server.',('bind9','bind9-utils'),('bind9',),('named','rndc')),
 'postfix':AppSpec('postfix','Postfix','Mail','SMTP mail transport.',('postfix',),('postfix',),('postfix',)),
 'dovecot':AppSpec('dovecot','Dovecot','Mail','IMAP/POP3/LMTP mail delivery service.',('dovecot-core','dovecot-imapd','dovecot-pop3d','dovecot-lmtpd','dovecot-sieve'),('dovecot',),('dovecot',)),
 'opendkim':AppSpec('opendkim','OpenDKIM','Mail','DKIM signing and verification service.',('opendkim','opendkim-tools'),('opendkim',),('opendkim','opendkim-genkey')),
 'vsftpd':AppSpec('vsftpd','vsftpd','FTP','FTP server.',('vsftpd',),('vsftpd',),('vsftpd',)),
 'certbot':AppSpec('certbot','Certbot','SSL',"Let's Encrypt certificate client.",('certbot',),('',),('certbot',)),
 'docker':AppSpec('docker','Docker','Containers','Docker container engine.',('docker.io',),('docker',),('docker',)),
 'ufw':AppSpec('ufw','UFW','Firewall','Ubuntu uncomplicated firewall.',('ufw',),('ufw',),('ufw',)),
 'firewalld':AppSpec('firewalld','firewalld','Firewall','Dynamic firewall daemon.',('firewalld',),('firewalld',),('firewall-cmd',)),
}
for v in ('8.1','8.2','8.3','8.4','8.5'):
 k=f'php-{v.replace(".","_")}-fpm'; SPECS[k]=AppSpec(k,f'PHP {v} / PHP-FPM','Runtime',f'PHP {v} runtime with FPM process manager.',(f'php{v}-fpm',),(f'php{v}-fpm',),(f'php{v}',),True)

# Software-specific config files. Only these paths are editable through the UI.
CONFIGS={
 'nginx':Path('/etc/nginx/nginx.conf'),'apache':Path('/etc/apache2/apache2.conf'),'mariadb':Path('/etc/mysql/mariadb.conf.d/50-server.cnf'),
 'mysql':Path('/etc/mysql/mysql.conf.d/mysqld.cnf'),'bind9':Path('/etc/bind/named.conf.options'),'postfix':Path('/etc/postfix/main.cf'),
 'dovecot':Path('/etc/dovecot/dovecot.conf'),'opendkim':Path('/etc/opendkim.conf'),'vsftpd':Path('/etc/vsftpd.conf'),'docker':Path('/etc/docker/daemon.json'),
 'ufw':Path('/etc/default/ufw'),'firewalld':Path('/etc/firewalld/firewalld.conf')}

class ProviderError(RuntimeError): pass

def _run(argv:list[str],timeout:int=900,cancel_check=None,input_text=None)->tuple[int,str]:
    if cancel_check and cancel_check(): raise ProviderError('Operation cancelled before command execution.')
    p=subprocess.run(argv,input=input_text,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True,timeout=timeout,env={**os.environ,'DEBIAN_FRONTEND':'noninteractive'})
    if cancel_check and cancel_check(): raise ProviderError('Operation cancellation requested.')
    return p.returncode,p.stdout[-12000:]

def _dpkg_map():
    if not shutil.which('dpkg-query'): return {}
    rc,out=_run(['dpkg-query','-W','-f=${binary:Package}\t${Status}\t${Version}\n'],30)
    if rc and not out: return {}
    m={}
    for line in out.splitlines():
        p=line.split('\t',2)
        if len(p)>=3: m[p[0]]={'installed':'install ok installed' in p[1],'version':p[2]}
    return m

def _systemd_snapshot():
    units=set(); active=set(); enabled=set()
    if not shutil.which('systemctl'): return units,active,enabled
    rc,out=_run(['systemctl','list-unit-files','--type=service','--no-legend','--no-pager'],30)
    for line in out.splitlines():
        parts=line.split(); x=parts[0] if parts else ''
        if x.endswith('.service'):
            units.add(x[:-8])
            if len(parts)>1 and parts[1] in ('enabled','static','alias'): enabled.add(x[:-8])
    rc,out=_run(['systemctl','list-units','--type=service','--state=running','--no-legend','--no-pager'],30)
    for line in out.splitlines():
        x=line.split()[0] if line.split() else ''
        if x.endswith('.service'): active.add(x[:-8])
    return units,active,enabled

def _unit_name(spec:AppSpec,units=None)->Optional[str]:
    units=units or _systemd_snapshot()[0]
    for s in spec.services:
        if s and s in units:return s
    if spec.key=='php-fpm':
        return next((u for u in sorted(units) if re.fullmatch(r'php[0-9.]+-fpm',u)),None)
    return None

def _enabled_units(names):
    if not shutil.which('systemctl') or not names:return set()
    out=set()
    # one systemctl call with all names, then parse the output as active/enabled is not reliable.
    for u in names:
        rc,text=_run(['systemctl','is-enabled',u],12)
        if rc==0 and text.strip() in ('enabled','static','alias'):out.add(u)
    return out

def _pkg_state(spec,dpkg):
    pkgs=[p for p in spec.packages if p and dpkg.get(p,{}).get('installed')]
    if spec.key=='php-fpm': pkgs=sorted([p for p,v in dpkg.items() if v.get('installed') and re.fullmatch(r'php[0-9.]+-fpm',p)])
    return pkgs

def detect(key:str, snapshots=None)->dict:
    if key not in SPECS: raise ProviderError('Unknown application.')
    spec=SPECS[key]; dpkg,units,active,enabled=snapshots or (_dpkg_map(),*_systemd_snapshot())
    installed_packages=_pkg_state(spec,dpkg); unit=_unit_name(spec,units)
    running=bool(unit and unit in active); en=bool(unit and unit in enabled)
    binary=next((b for b in spec.binary_candidates if shutil.which(b)), '')
    if spec.key=='phpmyadmin' and Path('/usr/share/phpmyadmin').is_dir():
        binary='/usr/share/phpmyadmin'
    version=dpkg.get(installed_packages[0],{}).get('version','') if installed_packages else ''
    if not version and binary:
        try:
            rc,out=_run([binary,'--version'],15); version=out.splitlines()[0].strip() if rc==0 and out else ''
        except Exception: pass
    return {'key':key,'name':spec.name,'category':spec.category,'description':spec.description,'installed':bool(installed_packages or binary),'packages':installed_packages,'service':unit or '','running':running,'enabled':en,'version':version,'dependencies':[],'dynamic':False,'configurable':key in CONFIGS,'health':running if unit else bool(installed_packages or binary)}

def discover_dynamic_units(known_keys=None):
    known=set()
    units,_,enabled= _systemd_snapshot()
    for k in (known_keys or SPECS):
        u=_unit_name(SPECS[k],units)
        if u: known.add(u)
    out=[]
    for u in sorted(units):
        if u in known or u in {'systemd-journald','systemd-logind','systemd-udevd','getty','cron','ssh','dbus','networkd-dispatcher'}: continue
        key='unit__'+re.sub(r'[^A-Za-z0-9_.-]','_',u)
        out.append({'key':key,'name':u,'category':'System Service','description':'Dynamically discovered systemd service.','packages':[],'service':u,'installed':True,'running':False,'enabled':u in enabled,'version':'','dependencies':[],'dynamic':True,'configurable':False,'health':False})
    # One bulk active snapshot for dynamic units.
    _,active,_=_systemd_snapshot()
    for x in out: x['running']=x['service'] in active; x['health']=x['running']
    return out

def _retention_days():
    try:
        p=Path('/etc/scpanel/config.json'); d=json.loads(p.read_text()) if p.exists() else {}; return max(1,min(365,int(d.get('log_retention_days',30))))
    except Exception:return 30

def prune_logs():
    cutoff=time.time()-86400*_retention_days()
    try:
        for p in LOG_DIR.glob('*.log'):
            if p.stat().st_mtime<cutoff:p.unlink(missing_ok=True)
    except Exception:pass

def _log_path(key):return LOG_DIR/f'{key}.log'
def _write_log(key,action,text):
    with _log_path(key).open('a',encoding='utf-8') as f:f.write(f'\n[{time.strftime("%Y-%m-%d %H:%M:%S")}] {action}\n{text}\n')

def _load_persistent_cache():
    try:
        if not CACHE_FILE.exists(): return []
        d=json.loads(CACHE_FILE.read_text())
        return d if isinstance(d,list) else []
    except Exception:
        return []

def _save_persistent_cache(data):
    try:
        CACHE_FILE.parent.mkdir(parents=True,exist_ok=True)
        tmp=CACHE_FILE.with_suffix('.tmp')
        tmp.write_text(json.dumps(data,separators=(',',':')),encoding='utf-8')
        tmp.replace(CACHE_FILE)
    except Exception:
        pass

def background_refresh():
    """Refresh service inventory outside user-facing request paths."""
    with _refresh_lock:
        try: catalog(force=True)
        except Exception: pass

def php_runtime_versions():
    """Return PHP versions actually installed or available in the OS repo."""
    installed=[]; available=[]
    try:
        dpkg=_dpkg_map()
        for v in ('8.1','8.2','8.3','8.4','8.5'):
            if dpkg.get(f'php{v}-fpm',{}).get('installed'): installed.append(v)
            pkg=f'php{v}-fpm'
            if shutil.which('apt-cache'):
                rc,out=_run(['apt-cache','madison',pkg],30)
                if rc==0 and any('|' in line and line.split('|',1)[1].strip() for line in out.splitlines()): available.append(v)
    except Exception: pass
    return sorted(set(installed+available),key=lambda x: tuple(map(int,x.split('.'))))

def catalog(force=False):
    global _catalog_cache
    now=time.time()
    if not force and _catalog_cache['data'] and now-_catalog_cache['ts']<CATALOG_TTL:return _catalog_cache['data']
    if not force and not _catalog_cache['data']:
        persisted=_load_persistent_cache()
        if persisted:
            _catalog_cache={'ts':now,'data':persisted}
            return persisted
    prune_logs(); dpkg=_dpkg_map(); units,active,enabled=_systemd_snapshot(); known_units=[]; data=[]
    for k,spec in SPECS.items():
        pkgs=_pkg_state(spec,dpkg); unit=_unit_name(spec,units); known_units.append(unit) if unit else None
        en=bool(unit and unit in enabled) if unit else False
        binary=next((b for b in spec.binary_candidates if shutil.which(b)), '')
        ver=dpkg.get(pkgs[0],{}).get('version','') if pkgs else ''
        data.append({'key':k,'name':spec.name,'category':spec.category,'description':spec.description,'installed':bool(pkgs or binary),'packages':pkgs,'service':unit or '','running':bool(unit and unit in active),'enabled':en,'version':ver,'dependencies':[],'dynamic':False,'configurable':k in CONFIGS,'health':bool(unit and unit in active) if unit else bool(pkgs or binary)})
    known=set(known_units)
    ignore={'systemd-journald','systemd-logind','systemd-udevd','getty','cron','ssh','dbus','networkd-dispatcher'}
    for u in sorted(units-known):
        if u in ignore or u.startswith('systemd-'):continue
        key='unit__'+re.sub(r'[^A-Za-z0-9_.-]','_',u)
        data.append({'key':key,'name':u,'category':'System Service','description':'Dynamically discovered systemd service.','installed':True,'packages':[],'service':u,'running':u in active,'enabled':u in enabled,'version':'','dependencies':[],'dynamic':True,'configurable':False,'health':u in active})
    _catalog_cache={'ts':now,'data':data}; _save_persistent_cache(data); return data

def _spec_for_key(key):
    if key in SPECS:return SPECS[key],None
    if key.startswith('unit__'):
        unit=key[6:];
        if re.fullmatch(r'[A-Za-z0-9_.@:-]+',unit):return None,unit
    raise ProviderError('Unknown service/application.')

def _state(key):
    if key in SPECS:return detect(key)
    spec,unit=_spec_for_key(key); units,active,enabled=_systemd_snapshot()
    if unit not in units:raise ProviderError('Unknown or unavailable systemd service.')
    return {'key':key,'name':unit,'category':'System Service','description':'Dynamically discovered systemd service.','installed':True,'packages':[],'service':unit,'running':unit in active,'enabled':unit in enabled,'version':'','dependencies':[],'dynamic':True,'configurable':False,'health':unit in active}

def available_versions(key):
    spec,_=_spec_for_key(key); versions=[]
    if not spec:return versions
    dpkg=_dpkg_map(); installed_values=set()
    for pkg,v in dpkg.items():
        if v.get('installed') and pkg in spec.packages: installed_values.add(f'{pkg}={v.get("version","")}')
    seen=set()
    for pkg in spec.packages:
        if not pkg or not shutil.which('apt-cache'): continue
        rc,out=_run(['apt-cache','madison',pkg],30)
        if rc!=0: continue
        for line in out.splitlines():
            parts=[x.strip() for x in line.split('|')]
            if len(parts)>=2 and parts[1] and parts[1]!='Version':
                value=f'{pkg}={parts[1]}'
                if value not in seen:
                    seen.add(value); versions.append({'value':value,'label':f'{pkg} · {parts[1]}','channel':'Older','installed':value in installed_values})
    versions.sort(key=lambda x: (x['label']), reverse=True)
    if versions:
        versions[0]['channel']='Latest'
        if len(versions)>1:
            # Mark the newest version matching the installed package as Stable when possible.
            stable_idx=next((i for i,v in enumerate(versions) if v['installed']),1)
            versions[stable_idx]['channel']='Stable'
        for v in versions: v['installed']=bool(v.get('installed'))
    # Installed is represented explicitly but only from dpkg state, never invented.
    for value in installed_values:
        if value not in {v['value'] for v in versions}:
            versions.append({'value':value,'label':value,'channel':'Installed','installed':True})
    return versions[:50]

def _ensure_php_repository(version, progress=None, cancel_check=None):
    """Ensure a repository capable of supplying modern PHP on Ubuntu/Debian.
    Never claim availability until apt-cache can actually see the requested FPM package.
    """
    if not version or version == '8.1': return
    pkg=f'php{version}-fpm'
    if shutil.which('apt-cache'):
        rc,out=_run(['apt-cache','madison',pkg],30)
        if rc==0 and any('|' in line and line.split('|',1)[1].strip() for line in out.splitlines()): return
    if progress: progress(8, f'Preparing verified PHP {version} repository')
    if not shutil.which('add-apt-repository'):
        rc,out=_run(['apt-get','update','-y'],900,cancel_check)
        if rc: raise ProviderError('Cannot refresh package repositories for PHP runtime.')
        rc,out=_run(['apt-get','install','-y','software-properties-common','ca-certificates','gnupg'],900,cancel_check)
        if rc: raise ProviderError('Cannot install repository management prerequisites.')
    # Ondřej Surý PHP PPA is the supported multi-version source commonly used on Ubuntu.
    if shutil.which('add-apt-repository'):
        rc,out=_run(['add-apt-repository','-y','ppa:ondrej/php'],300,cancel_check)
        if rc: raise ProviderError('PHP repository could not be enabled: '+out[-1000:])
    rc,out=_run(['apt-get','update','-y'],900,cancel_check)
    if rc: raise ProviderError('PHP repository package index refresh failed: '+out[-1000:])
    rc,out=_run(['apt-cache','madison',pkg],30)
    if rc!=0 or not any('|' in line and line.split('|',1)[1].strip() for line in out.splitlines()):
        raise ProviderError(f'PHP {version} is not available from the verified OS repositories after repository setup.')

def install(key,progress=None,requested_version='',cancel_check=None):
    spec,_=_spec_for_key(key)
    if not spec:raise ProviderError('Dynamic services cannot be installed from App Store.')
    progress=progress or (lambda p,s:None); before=detect(key)
    if before['installed']:return {**before,'changed':False,'message':'Already installed.'}
    php_match=re.fullmatch(r'php-(8_[1-5])-fpm',key or '')
    if php_match: _ensure_php_repository(php_match.group(1).replace('_','.'),progress,cancel_check)
    if key=='phpmyadmin':
        progress(8,'Preparing phpMyAdmin web integration')
        if shutil.which('debconf-set-selections'):
            webserver='apache2' if shutil.which('apache2') else ''
            seed=f'phpmyadmin phpmyadmin/reconfigure-webserver multiselect {webserver}\nphpmyadmin phpmyadmin/dbconfig-install boolean false\n'
            _run(['debconf-set-selections'],30,input_text=seed)
    _write_log(key,'install','Starting apt installation: '+' '.join(spec.packages)); progress(10,'Updating package index')
    rc,out=_run(['apt-get','update','-y'],900,cancel_check); _write_log(key,'apt-get update',out)
    if rc:raise ProviderError('apt-get update failed. See installation log.')
    progress(35,'Installing packages'); pkgs=list(spec.packages)
    if requested_version and re.fullmatch(r'[a-zA-Z0-9+_.:@/-]+=[^=\s]+',requested_version):
        pkgname=requested_version.split('=',1)[0]
        if php_match and pkgname != spec.packages[0]: raise ProviderError('Selected package does not match the requested PHP runtime.')
        pkgs=[requested_version]
    rc,out=_run(['apt-get','install','-y',*pkgs],1800,cancel_check); _write_log(key,'apt-get install',out)
    if rc:raise ProviderError('Package installation failed. See installation log.')
    if key=='phpmyadmin':
        progress(65,'Configuring phpMyAdmin web integration')
        _configure_phpmyadmin()
    progress(65,'Configuring service'); unit=_unit_name(spec)
    try:
        if unit:
            rc,out=_run(['systemctl','enable','--now',unit],120,cancel_check); _write_log(key,'enable --now',out)
            if rc:raise ProviderError('Service installed but could not be started.')
        progress(85,'Starting service'); after=detect(key); progress(95,'Health check')
        if not after['installed']:raise ProviderError('Installation completed but detection failed.')
        if unit and not after['running']:raise ProviderError('Installation completed but service health check failed.')
        return {**after,'changed':True,'message':'Installed, configured, started and verified.'}
    except Exception:
        _write_log(key,'rollback','Installation failed; attempting package rollback.')
        _run(['apt-get','remove','-y',*spec.packages],1800)
        raise


def _configure_phpmyadmin():
    """Best-effort, safe web integration for the distro phpMyAdmin package.
    Apache is integrated when its packaged phpMyAdmin config is available.
    Nginx is intentionally not rewritten here because SCpanel-managed virtual
    hosts must own their server blocks; the Database Manager will only expose
    the Open button when /phpmyadmin is a reachable web endpoint.
    """
    if not Path('/usr/share/phpmyadmin').is_dir():
        raise ProviderError('phpMyAdmin package installed but /usr/share/phpmyadmin is missing.')
    if shutil.which('a2enconf'):
        candidates=[Path('/etc/apache2/conf-available/phpmyadmin.conf'),Path('/etc/phpmyadmin/apache.conf')]
        target=next((p for p in candidates if p.exists()),None)
        if target and target.parent.name=='conf-available':
            rc,out=_run(['a2enconf','phpmyadmin'],30)
            if rc: raise ProviderError('Could not enable Apache phpMyAdmin configuration: '+out[-1000:])
            if shutil.which('apache2ctl'):
                rc,out=_run(['apache2ctl','configtest'],30)
                if rc: raise ProviderError('Apache configuration validation failed: '+out[-1000:])
            if shutil.which('systemctl'):
                rc,out=_run(['systemctl','reload','apache2'],30)
                if rc: raise ProviderError('Apache reload failed: '+out[-1000:])


def uninstall(key,progress=None,cancel_check=None):
    spec,_=_spec_for_key(key)
    if not spec:raise ProviderError('Dynamic services cannot be uninstalled from App Store.')
    progress=progress or (lambda p,s:None); before=detect(key)
    if not before['installed']:return {**before,'changed':False,'message':'Already not installed.'}
    unit=before.get('service')
    if unit:
        progress(20,'Stopping service'); rc,out=_run(['systemctl','disable','--now',unit],120,cancel_check); _write_log(key,'disable --now',out)
        if rc:raise ProviderError('Could not stop/disable service safely.')
    progress(50,'Removing packages'); pkgs=before.get('packages') or list(spec.packages); rc,out=_run(['apt-get','remove','-y',*pkgs],1800,cancel_check); _write_log(key,'apt-get remove',out)
    if rc:raise ProviderError('Package removal failed. See log.')
    progress(90,'Health check'); after=detect(key)
    if after['installed']:raise ProviderError('Uninstall completed but service/package is still detected.')
    return {**after,'changed':True,'message':'Uninstalled and verified.'}

def action(key,operation,progress=None,requested_version='',cancel_check=None):
    spec,unit=_spec_for_key(key)
    if operation=='install':return install(key,progress,requested_version,cancel_check)
    if operation=='uninstall':return uninstall(key,progress,cancel_check)
    if operation=='reinstall':uninstall(key,progress,cancel_check);return install(key,progress,requested_version,cancel_check)
    state=_state(key); service=state.get('service')
    if operation in {'start','stop','restart','enable','disable','reload'}:
        if not service:raise ProviderError('No systemd service is available.')
        cmd=['systemctl',operation,service];
        if progress:progress(45,f'systemctl {operation}')
        rc,out=_run(cmd,120,cancel_check); _write_log(key,operation,out)
        if rc:raise ProviderError(f'systemctl {operation} failed. See log.')
        _catalog_cache['ts']=0
        return {**_state(key),'changed':True,'message':f'{operation.capitalize()} completed.'}
    if operation=='status':return state
    if operation=='version':return {'key':key,'version':state.get('version','')}
    if operation=='health':return {'key':key,'healthy':bool(state.get('health')),'running':state.get('running',False),'installed':state.get('installed',False),'service':service or ''}
    if operation=='logs':
        local=_log_path(key).read_text(errors='replace')[-12000:] if _log_path(key).exists() else ''; journal=''
        if service and shutil.which('journalctl'):
            rc,out=_run(['journalctl','-u',service,'-n','100','--no-pager','-o','short-iso'],30); journal=out if rc==0 else ''
        return {'key':key,'log':(local+'\n\n--- systemd journal ---\n'+journal).strip() or 'No log yet.'}
    raise ProviderError('Unsupported operation.')

def _php_version_from_key(key):
    m=re.fullmatch(r'php-(8_[1-5])-fpm',key or '')
    return m.group(1).replace('_','.') if m else None

def _php_ini_path(key):
    v=_php_version_from_key(key)
    return Path(f'/etc/php/{v}/fpm/php.ini') if v else None

def _php_fpm_pool_path(key):
    v=_php_version_from_key(key)
    return Path(f'/etc/php/{v}/fpm/pool.d/www.conf') if v else None

def _php_config_path(key):
    return _php_fpm_pool_path(key)

def _update_ini_file(path, updates):
    path.parent.mkdir(parents=True,exist_ok=True); text=path.read_text(errors='replace') if path.exists() else ''
    lines=text.splitlines(); seen=set(); out=[]
    for line in lines:
        m=re.match(r'^\s*[;#]?\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$',line)
        if m and m.group(1) in updates:
            key=m.group(1); out.append(f'{key} = {updates[key]}'); seen.add(key)
        else: out.append(line)
    for key,val in updates.items():
        if key not in seen: out.append(f'{key} = {val}')
    backup=text if path.exists() else None; path.write_text('\n'.join(out)+'\n',encoding='utf-8'); return backup

def save_php_settings(key, settings):
    if not _php_version_from_key(key): raise ProviderError('Invalid PHP runtime key.')
    allowed={'memory_limit','max_execution_time','max_input_time','post_max_size','upload_max_filesize','max_file_uploads','display_errors','date.timezone'}
    updates={k:str(v).strip() for k,v in (settings or {}).items() if k in allowed}
    if not updates: raise ProviderError('No supported PHP settings supplied.')
    p=_php_ini_path(key); backup=_update_ini_file(p,updates); ok,msg=validate_config(key)
    if not ok:
        if backup is None:p.unlink(missing_ok=True)
        else:p.write_text(backup,encoding='utf-8')
        raise ProviderError('PHP configuration validation failed; previous configuration restored. '+msg)
    unit=f'php{_php_version_from_key(key)}-fpm'; rc,out=_run(['systemctl','reload',unit],30)
    if rc:
        if backup is None:p.unlink(missing_ok=True)
        else:p.write_text(backup,encoding='utf-8')
        raise ProviderError('PHP-FPM reload failed; previous configuration restored. '+out[-1000:])
    return {'path':str(p),'validated':True,'reloaded':True,'message':'PHP settings validated, saved and reloaded.'}

def save_fpm_settings(key, settings):
    v=_php_version_from_key(key)
    if not v: raise ProviderError('Invalid PHP runtime key.')
    allowed={'pm','pm.max_children','pm.start_servers','pm.min_spare_servers','pm.max_spare_servers','request_terminate_timeout'}
    updates={k:str(val).strip() for k,val in (settings or {}).items() if k in allowed and str(val).strip()}
    p=_php_fpm_pool_path(key); backup=p.read_text(errors='replace') if p.exists() else ''
    text=backup
    for k,val in updates.items():
        pattern=re.compile(r'^\s*'+re.escape(k)+r'\s*=.*$',re.M)
        line=f'{k} = {val}'
        if pattern.search(text): text=pattern.sub(line,text)
        else: text=text.rstrip()+'\n'+line+'\n'
    p.parent.mkdir(parents=True,exist_ok=True); p.write_text(text,encoding='utf-8'); ok,msg=validate_config(key)
    if not ok:
        p.write_text(backup,encoding='utf-8'); raise ProviderError('PHP-FPM validation failed; previous configuration restored. '+msg)
    unit=f'php{v}-fpm'; rc,out=_run(['systemctl','reload',unit],30)
    if rc:
        p.write_text(backup,encoding='utf-8'); raise ProviderError('PHP-FPM reload failed; previous configuration restored. '+out[-1000:])
    return {'path':str(p),'validated':True,'reloaded':True,'message':'FPM profile validated, saved and reloaded.'}

def config_info(key):
    p=_php_config_path(key) if key.startswith('php-') else CONFIGS.get(key)
    if not p:return {'key':key,'editable':False,'path':'','content':'','message':'No software-specific editor is available for this service.'}
    return {'key':key,'editable':True,'path':str(p),'content':p.read_text(errors='replace') if p.exists() else ''}

def validate_config(key):
    tests={'nginx':['nginx','-t'],'apache':['apache2ctl','configtest'],'mariadb':['mariadbd','--validate-config'],'mysql':['mysqld','--validate-config'],'bind9':['named-checkconf','/etc/bind/named.conf'],'postfix':['postfix','check'],'dovecot':['dovecot','-n'],'vsftpd':['vsftpd','-olisten=NO']}
    if key.startswith('php-'):
        m=re.fullmatch(r'php-(8_[1-5])-fpm',key or ''); tests[key]=[f'php-fpm{m.group(1).replace("_",".")}', '-t'] if m else []
    if key=='docker':
        try:
            json.loads(CONFIGS[key].read_text(errors='replace'))
            return True,'Docker daemon JSON syntax is valid.'
        except Exception as exc:
            return False,'Docker daemon JSON validation failed: '+str(exc)
    cmd=tests.get(key)
    if not cmd or not shutil.which(cmd[0]):return False,'No safe syntax-validation command is available for this software; configuration was not reloaded.'
    rc,out=_run(cmd,30); return rc==0,out[-2000:]

def save_config(key,content):
    p=_php_config_path(key) if key.startswith('php-') else CONFIGS.get(key)
    if not p:raise ProviderError('No editable software-specific configuration is available.')
    if not isinstance(content,str) or len(content)>2*1024*1024:raise ProviderError('Invalid configuration payload.')
    p=p; p.parent.mkdir(parents=True,exist_ok=True); backup=p.read_text(errors='replace') if p.exists() else None
    p.write_text(content,encoding='utf-8'); ok,msg=validate_config(key)
    if not ok:
        if backup is None:p.unlink(missing_ok=True)
        else:p.write_text(backup,encoding='utf-8')
        raise ProviderError('Configuration validation failed; previous configuration restored. '+msg)
    unit=SPECS[key].services[0] if key in SPECS and SPECS[key].services else ''
    if key.startswith('php-'):
        unit='php'+key[4:].replace('_','.')
    if unit:
        rc,out=_run(['systemctl','reload',unit],30)
        if rc:
            if backup is None:p.unlink(missing_ok=True)
            else:p.write_text(backup,encoding='utf-8')
            raise ProviderError('Reload failed; previous configuration restored. '+out[-1000:])
    _write_log(key,'config.save','Validated and reloaded successfully.')
    return {'path':str(p),'validated':True,'reloaded':bool(unit),'message':'Configuration saved, validated and safely reloaded.'}
