"""cPanel deployment helper for Prisca AI.

Uses cPanel UAPI/PassengerApps. API tokens are preferred over passwords.
The deployer never logs secrets and only runs from an authenticated Admin session.
"""
import os, json, ssl, base64, re, urllib.request, urllib.parse, urllib.error, mimetypes
from pathlib import Path

class CPanelError(RuntimeError): pass

class CPanelClient:
    def __init__(self, base_url, username, token='', password='', timeout=45, verify_ssl=True):
        base_url=(base_url or '').strip()
        if not base_url: raise CPanelError('cPanel URL is required')
        if not base_url.startswith(('http://','https://')): base_url='https://'+base_url
        self.base=base_url.rstrip('/')
        if ':2083' not in self.base and ':2082' not in self.base:
            self.base += ':2083'
        self.username=username.strip()
        self.token=token.strip(); self.password=password
        self.timeout=timeout
        self.ctx=None if verify_ssl else ssl._create_unverified_context()
        if not self.username or not (self.token or self.password):
            raise CPanelError('cPanel username and API token/password are required')
    def _headers(self):
        if self.token:
            return {'Authorization':f'cpanel {self.username}:{self.token}','User-Agent':'Prisca-AI-Worker/1.0'}
        raw=base64.b64encode(f'{self.username}:{self.password}'.encode()).decode()
        return {'Authorization':'Basic '+raw,'User-Agent':'Prisca-AI-Worker/1.0'}
    def call(self,module,function,params=None,method='GET',body=None,content_type=None):
        params=params or {}
        url=f'{self.base}/execute/{urllib.parse.quote(module)}/{urllib.parse.quote(function)}'
        data=None
        if method=='GET' and params:
            url += '?' + urllib.parse.urlencode(params)
        elif params:
            data=urllib.parse.urlencode(params).encode()
            content_type=content_type or 'application/x-www-form-urlencoded'
        if body is not None: data=body
        h=self._headers()
        if content_type: h['Content-Type']=content_type
        req=urllib.request.Request(url,data=data,headers=h,method=method)
        try:
            with urllib.request.urlopen(req,timeout=self.timeout,context=self.ctx) as r:
                raw=r.read().decode('utf-8','replace')
                out=json.loads(raw) if raw else {}
        except urllib.error.HTTPError as e:
            raw=e.read().decode('utf-8','replace')
            try: out=json.loads(raw)
            except Exception: out={'error':raw}
            raise CPanelError(f'cPanel HTTP {e.code}: {out.get("error") or out.get("errors") or out}')
        except Exception as e:
            raise CPanelError(f'cPanel connection failed: {e}')
        result=out.get('result',{}) if isinstance(out,dict) else {}
        if isinstance(result,dict) and result.get('status')==0:
            errs=result.get('errors') or result.get('messages') or ['cPanel API call failed']
            raise CPanelError('; '.join(map(str,errs)))
        return out
    def test(self):
        out=self.call('Version','get_version')
        version=(out.get('result') or {}).get('data')
        return {'ok':True,'version':version,'endpoint':self.base+'/execute'}
    def register_application(self, name, path, domain, startup_file='passenger_wsgi.py', app_uri='/', python_version='3.12'):
        # cPanel's PassengerApps API accepts these fields on supported versions.
        domain=re.sub(r'^https?://', '', (domain or '').strip(), flags=re.I).rstrip('/')
        if not domain:
            raise CPanelError('Application domain is required')
        params={'name':name,'path':path,'domain':domain,'deployment_mode':'production','enabled':'1','startup_file':startup_file}
        if app_uri: params['app_uri']=app_uri
        if python_version: params['python_version']=python_version
        return self.call('PassengerApps','register_application',params)
    def ensure_deps(self, app_path):
        return self.call('PassengerApps','ensure_deps',{'type':'pip','app_path':app_path})
    def enable_application(self,name):
        return self.call('PassengerApps','enable_application',{'name':name})
    def upload_files(self, directory, files):
        # UAPI Fileman::upload_files requires multipart/form-data.
        boundary='----PriscaBoundary9d4e7f'
        chunks=[]
        def field(name,value,filename=None,ctype='application/octet-stream'):
            if filename:
                chunks.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"; filename="{filename}"\r\nContent-Type: {ctype}\r\n\r\n'.encode()+value+b'\r\n')
            else:
                chunks.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode())
        field('dir',directory.encode())
        for i,(name,data) in enumerate(files,1):
            field(f'file-{i}',data,name,mimetypes.guess_type(name)[0] or 'application/octet-stream')
        chunks.append(f'--{boundary}--\r\n'.encode())
        return self.call('Fileman','upload_files',method='POST',body=b''.join(chunks),content_type=f'multipart/form-data; boundary={boundary}')
    def mkdir_legacy(self,path,name):
        # UAPI does not expose a stable mkdir operation on all cPanel versions; use API2 only for directory creation.
        url=f'{self.base.replace(":2083",":2083")}/json-api/cpanel'
        params={'cpanel_jsonapi_apiversion':'2','cpanel_jsonapi_module':'Fileman','cpanel_jsonapi_func':'mkdir','path':path,'name':name,'permissions':'0755'}
        full=url+'?'+urllib.parse.urlencode(params)
        req=urllib.request.Request(full,headers=self._headers(),method='GET')
        try:
            with urllib.request.urlopen(req,timeout=self.timeout,context=self.ctx) as r:
                out=json.loads(r.read().decode('utf-8','replace'))
        except Exception as e: raise CPanelError(f'Could not create cPanel directory {name}: {e}')
        if not out.get('cpanelresult',{}).get('event',{}).get('result',1):
            raise CPanelError(f'Could not create cPanel directory {name}: {out}')
        return out

def collect_deploy_files(root):
    root=Path(root); files=[]
    skip_parts={'.git','__pycache__','.pytest_cache'}
    skip_names={'prisca.db','.privacy.key'}
    for p in sorted(root.rglob('*')):
        if not p.is_file(): continue
        rel=p.relative_to(root).as_posix()
        if any(part in skip_parts for part in p.parts): continue
        if p.name in skip_names: continue
        if rel.startswith(('data/','uploads/')): continue
        if rel.lower().endswith(('.zip','.exe','.pyc')): continue
        files.append((rel,p.read_bytes()))
    return files

def deploy(root, cfg):
    client=CPanelClient(cfg['cpanel_url'],cfg['cpanel_user'],cfg.get('cpanel_token',''),cfg.get('cpanel_password',''),verify_ssl=cfg.get('verify_ssl',True))
    test=client.test()
    app_path=(cfg.get('app_path') or cfg.get('cpanel_app_path') or 'prisca_ai').strip('/').replace('..','')
    # V26 stored this setting as cpanel_app_domain. Accept both the normalized
    # deployer name and the UI/database name so saved settings are upgrade-safe.
    domain=(cfg.get('app_domain') or cfg.get('cpanel_app_domain') or cfg.get('domain') or '').strip()
    if domain:
        domain=re.sub(r'^https?://', '', domain, flags=re.I).rstrip('/')
    if not domain: raise CPanelError('Application domain is required')
    home_dir=cfg.get('home_dir','')
    # Upload into a home-relative app directory. Create nested dirs as needed.
    parts=app_path.split('/') if app_path else []
    current=home_dir or '.'
    # cPanel Fileman dir values are home-relative; ensure root directory exists.
    built=''
    for part in parts:
        parent=built or '.'
        try: client.mkdir_legacy(parent,part)
        except Exception:
            pass
        built=(built+'/'+part).strip('/')
    files=collect_deploy_files(root)
    # Upload in conservative batches to avoid request-size limits.
    batches=[]
    batch=[]; size=0
    for rel,data in files:
        if size+len(data)>3*1024*1024 and batch: batches.append(batch); batch=[]; size=0
        batch.append((rel,data)); size+=len(data)
    if batch: batches.append(batch)
    uploaded=0
    for batch in batches:
        # Fileman upload destination can accept nested paths only if directories exist.
        # For nested files, group by directory.
        groups={}
        for rel,data in batch:
            d=str(Path(rel).parent).replace('\\','/')
            groups.setdefault(d,[]).append((Path(rel).name,data))
        for subdir,items in groups.items():
            dest=app_path if subdir=='.' else app_path+'/'+subdir
            # Create directory chain using API2 for compatibility.
            chain=[]
            acc=''
            for part in dest.split('/'):
                acc=(acc+'/'+part).strip('/'); chain.append(acc)
            for path in chain:
                parent=str(Path(path).parent).replace('\\','/')
                name=Path(path).name
                try: client.mkdir_legacy(parent if parent!='.' else '.',name)
                except Exception: pass
            client.upload_files(dest,items); uploaded += len(items)
    reg=client.register_application('Prisca AI',app_path,domain)
    try: deps=client.ensure_deps(app_path)
    except Exception as e: deps={'warning':str(e)}
    try: client.enable_application('Prisca AI')
    except Exception: pass
    return {'ok':True,'test':test,'uploaded_files':uploaded,'app_path':app_path,'domain':domain,'registration':reg,'dependencies':deps}
