#!/usr/bin/env bash
set -Eeuo pipefail

if [[ $(id -u) -ne 0 ]]; then echo '[THEX] Run as root.' >&2; exit 1; fi
export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a

THEX_VERSION='8.0.0'
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
THEX='/opt/thex'
PANEL="$THEX/panel"
AGENT="$THEX/agent"
WWW='/var/www'
LOG="$THEX/install.log"
LSPHP='/usr/local/lsws/lsphp74/bin/lsphp'
LSPHP_HOME='/usr/local/lsws/lsphp74'
OLS='/usr/local/lsws'
HOSTED_BASE='https://panel.thex.cam/release/php74'
PMA_HOSTED='https://panel.thex.cam/release/phpmyadmin/phpMyAdmin-5.2.3-all-languages.tar.gz'
PMA_OFFICIAL='https://files.phpmyadmin.net/phpMyAdmin/5.2.3/phpMyAdmin-5.2.3-all-languages.tar.gz'

mkdir -p "$THEX"
exec > >(tee -a "$LOG") 2>&1
trap 'echo "[THEX][ERROR] Installation stopped at line $LINENO. See $LOG" >&2' ERR
log(){ echo "[THEX] $*"; }
warn(){ echo "[THEX][WARN] $*" >&2; }
fail(){ echo "[THEX][ERROR] $*" >&2; exit 1; }

log "THEX v$THEX_VERSION — Ubuntu 22.04 / LSPHP 7.4 only"

# -----------------------------------------------------------------------------
# OS / architecture
# -----------------------------------------------------------------------------
. /etc/os-release
[[ ${ID:-} == ubuntu && ${VERSION_ID:-} == 22.04 ]] || fail "This release requires Ubuntu 22.04 LTS. Detected ${PRETTY_NAME:-unknown}."
ARCH=$(dpkg --print-architecture)
[[ $ARCH == amd64 || $ARCH == arm64 ]] || fail "Unsupported architecture: $ARCH"

# -----------------------------------------------------------------------------
# APT/DPKG lock handling. Ubuntu may start unattended-upgrades immediately
# after a fresh VPS boot. Wait for it instead of racing the package manager.
# -----------------------------------------------------------------------------
wait_for_pkg_manager(){
  local timeout=900 start=$(date +%s)
  while fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock >/dev/null 2>&1 || pgrep -x unattended-upgrade >/dev/null 2>&1 || pgrep -f 'apt.systemd.daily' >/dev/null 2>&1; do
    if (( $(date +%s)-start >= timeout )); then fail 'APT/DPKG is still busy after 15 minutes. Reboot once and rerun the installer.'; fi
    log 'Waiting for Ubuntu package manager to finish...'; sleep 5
  done
  dpkg --configure -a || fail 'dpkg configuration is incomplete.'
}
apt_cmd(){ wait_for_pkg_manager; apt-get -o DPkg::Lock::Timeout=600 "$@"; }

# -----------------------------------------------------------------------------
# Base packages — deliberately NO Ubuntu PHP packages.
# -----------------------------------------------------------------------------
log 'Installing base system packages (no system PHP)...'
apt_cmd update
apt_cmd install -y --no-install-recommends \
  ca-certificates curl wget gnupg openssl unzip zip tar xz-utils \
  python3 python3-bcrypt lsof procps psmisc sudo rsync \
  mariadb-server redis-server ufw fail2ban certbot

# -----------------------------------------------------------------------------
# Remove PHP 8.x/system PHP from previous test installs.
# Never remove LSPHP 7.4.
# -----------------------------------------------------------------------------
log 'Removing any Ubuntu PHP runtime from previous installations...'
mapfile -t SYSTEM_PHP_PKGS < <(dpkg-query -W -f='${binary:Package}\t${db:Status-Status}\n' 2>/dev/null | awk '$2=="installed" && $1 ~ /^php([0-9]|-|$)/ {print $1}') || true
if ((${#SYSTEM_PHP_PKGS[@]})); then
  log "Purging system PHP packages: ${SYSTEM_PHP_PKGS[*]}"
  apt_cmd purge -y "${SYSTEM_PHP_PKGS[@]}" || true
  apt_cmd autoremove -y || true
fi

# -----------------------------------------------------------------------------
# LiteSpeed repository + OpenLiteSpeed
# -----------------------------------------------------------------------------
if ! grep -Rqs 'repo.litespeedtech.com\|rpms.litespeedtech.com' /etc/apt/sources.list.d 2>/dev/null; then
  log 'Adding official LiteSpeed repository...'
  wget -qO- https://repo.litespeed.sh | bash
fi
apt_cmd update
apt_cmd install -y --no-install-recommends openlitespeed

# -----------------------------------------------------------------------------
# LSPHP 7.4.  Hosted packages are preferred; official LiteSpeed repo is the
# deterministic fallback. We never request lsphp75/80/81/etc or Ubuntu php-*.
# -----------------------------------------------------------------------------
php74_pkg_available(){ apt-cache show "$1" >/dev/null 2>&1; }
install_hosted_deb(){
  local url="$1" out="$2"
  if curl -fsSL --proto '=https' --tlsv1.2 "$url" -o "$out"; then
    dpkg-deb -I "$out" >/dev/null 2>&1 || { rm -f "$out"; return 1; }
    return 0
  fi
  rm -f "$out"; return 1
}

mkdir -p "$THEX/cache/php74"
HOSTED_PKGS=(
  'lsphp74_7.4.33-4+jammy_amd64.deb'
  'lsphp74-common_7.4.33-4+jammy_all.deb'
  'lsphp74-curl_7.4.33-4+jammy_amd64.deb'
  'lsphp74-mysql_7.4.33-4+jammy_amd64.deb'
  'lsphp74-opcache_7.4.33-4+jammy_amd64.deb'
  'lsphp74-intl_7.4.33-4+jammy_amd64.deb'
  'lsphp74-redis_5.3.7-1+jammy_amd64.deb'
  'lsphp74-imap_7.4.33-4+jammy_amd64.deb'
  'lsphp74-sqlite3_7.4.33-4+jammy_amd64.deb'
)

if [[ $ARCH == arm64 ]]; then
  HOSTED_PKGS=(
    'lsphp74_7.4.33-4+jammy_arm64.deb'
    'lsphp74-common_7.4.33-4+jammy_all.deb'
    'lsphp74-curl_7.4.33-4+jammy_arm64.deb'
    'lsphp74-mysql_7.4.33-4+jammy_arm64.deb'
    'lsphp74-opcache_7.4.33-4+jammy_arm64.deb'
    'lsphp74-intl_7.4.33-4+jammy_arm64.deb'
    'lsphp74-redis_5.3.7-1+jammy_arm64.deb'
    'lsphp74-imap_7.4.33-4+jammy_arm64.deb'
    'lsphp74-sqlite3_7.4.33-4+jammy_arm64.deb'
  )
fi

HOSTED_OK=1
for f in "${HOSTED_PKGS[@]}"; do
  if [[ ! -s "$THEX/cache/php74/$f" ]]; then
    if ! install_hosted_deb "$HOSTED_BASE/$f" "$THEX/cache/php74/$f"; then
      HOSTED_OK=0
      break
    fi
  fi
done

if (( HOSTED_OK )); then
  log 'Installing LSPHP 7.4 from panel.thex.cam hosted package cache...'
  dpkg -i "$THEX"/cache/php74/*.deb || apt_cmd -f install -y
else
  warn 'Hosted PHP 7.4 cache is incomplete; using the official LiteSpeed Jammy repository.'
  REQUIRED=(lsphp74 lsphp74-common lsphp74-curl lsphp74-mysql lsphp74-opcache)
  OPTIONAL=(lsphp74-intl lsphp74-redis lsphp74-imap lsphp74-sqlite3)
  for p in "${REQUIRED[@]}"; do php74_pkg_available "$p" || fail "Official LiteSpeed repository does not expose required package $p for $ARCH."; done
  apt_cmd install -y --no-install-recommends "${REQUIRED[@]}"
  EXTRA=()
  for p in "${OPTIONAL[@]}"; do php74_pkg_available "$p" && EXTRA+=("$p") || warn "Optional LSPHP package unavailable: $p"; done
  ((${#EXTRA[@]})) && apt_cmd install -y --no-install-recommends "${EXTRA[@]}"
fi

[[ -x "$LSPHP" ]] || fail "LSPHP 7.4 binary not found at $LSPHP"
# PHP 7.4 runtime limits for the cPanel-style file manager.
PHPINI="$LSPHP_HOME/etc/php/7.4/litespeed/php.ini"
if [[ -f "$PHPINI" ]]; then
  sed -i -E 's/^\s*upload_max_filesize\s*=.*/upload_max_filesize = 1024M/; s/^\s*post_max_size\s*=.*/post_max_size = 1024M/; s/^\s*memory_limit\s*=.*/memory_limit = 512M/; s/^\s*max_execution_time\s*=.*/max_execution_time = 300/' "$PHPINI"
fi


# LSPHP is an LSAPI SAPI binary, not a normal CLI PHP binary.
# Do NOT run it with `php -r`, `php -m`, or `php -S`: those flags produce the
# LSAPI usage screen. Validate the PHP 7.4 package and use OpenLiteSpeed to
# execute PHP requests.
LSPHP_PKG_VER="$(dpkg-query -W -f='${Version}' lsphp74 2>/dev/null || true)"
[[ "$LSPHP_PKG_VER" == 7.4.* ]] || fail "Wrong LSPHP package version: ${LSPHP_PKG_VER:-not-installed}"

# Keep a dedicated marker instead of replacing /usr/bin/php with an LSAPI
# binary. This prevents the exact "Wrong PHP runtime detected" failure.
ln -sf "$LSPHP" /usr/local/bin/lsphp74

log "LSPHP package: $LSPHP_PKG_VER"
log "LSPHP binary: $LSPHP"

# -----------------------------------------------------------------------------
# OpenLiteSpeed service: use the real unit, never aliases openlitespeed.service
# or lsws.service.
# -----------------------------------------------------------------------------
OLS_UNIT='lshttpd.service'
systemctl daemon-reload
systemctl enable --now "$OLS_UNIT"
systemctl is-active --quiet "$OLS_UNIT" || fail 'OpenLiteSpeed (lshttpd.service) is not running.'

# Configure server-level PHP handler in the actual OLS config.
CONF="$OLS/conf/httpd_config.conf"
[[ -f $CONF ]] || fail "Missing OpenLiteSpeed config: $CONF"
cp -a "$CONF" "$CONF.thex-v7-backup.$(date +%s)"
python3 - "$CONF" "$LSPHP" <<'PY'
import re,sys
p,php=sys.argv[1:]
s=open(p,encoding='utf-8',errors='ignore').read()
# Replace an existing lsphp external app if present.
pat=re.compile(r'extprocessor\s+lsphp\s*\{.*?\n\}',re.S)
block='''extprocessor lsphp {\n  type                    lsapi\n  address                 uds://tmp/lshttpd/lsphp.sock\n  maxConns                35\n  env                     PHP_LSAPI_CHILDREN=35\n  env                     LSAPI_AVOID_FORK=200M\n  initTimeout             60\n  retryTimeout            0\n  persistConn             1\n  respBuffer              0\n  autoStart               2\n  path                    %s\n  backlog                 100\n  instances               1\n}''' % php
if pat.search(s): s=pat.sub(block,s,count=1)
else: s += '\n\n'+block+'\n'
if not re.search(r'scripthandler\s*\{',s,re.S): s+='\n\nscripthandler {\n  add                     lsapi:lsphp php\n}\n'
open(p,'w',encoding='utf-8').write(s)
PY
# Disable the stock Example vhost before config validation. Some builds ship
# legacy uid/gid directives inside it that fail validation on current OLS.
python3 - <<'PYX'
from pathlib import Path
import re,shutil
p=Path('/usr/local/lsws/conf/httpd_config.conf')
s=p.read_text(errors='ignore')
b=p.with_name(p.name+'.thex-before-v8')
if not b.exists(): shutil.copy2(p,b)
s=re.sub(r'\n?virtualhost\s+Example\s*\{.*?\n\}\s*','\n',s,flags=re.S)
s=re.sub(r'(?m)^\s*map\s+Example\s+\*\s*$','',s)
p.write_text(s)
v=Path('/usr/local/lsws/conf/vhosts/Example')
if v.exists() and not Path('/usr/local/lsws/conf/vhosts/Example.disabled-by-thex').exists(): v.rename('/usr/local/lsws/conf/vhosts/Example.disabled-by-thex')
PYX
"$OLS/bin/openlitespeed" -t || fail 'OpenLiteSpeed configuration test failed. See the output above.'
systemctl restart "$OLS_UNIT"
systemctl is-active --quiet "$OLS_UNIT" || fail 'OpenLiteSpeed failed after configuration restart.'

# -----------------------------------------------------------------------------
# phpMyAdmin 5.2.3: compatible with PHP 7.2+, installed without Ubuntu's
# phpMyAdmin package (which can pull system PHP 8.1).
# -----------------------------------------------------------------------------
log 'Installing phpMyAdmin 5.2.3 without system PHP packages...'
PMA_TMP="$THEX/cache/phpmyadmin.tar.gz"
if [[ ! -s "$PMA_TMP" ]]; then
  if ! curl -fsSL --proto '=https' --tlsv1.2 "$PMA_HOSTED" -o "$PMA_TMP"; then
    log 'Hosted phpMyAdmin archive unavailable; downloading official phpMyAdmin 5.2.3.'
    curl -fsSL --proto '=https' --tlsv1.2 "$PMA_OFFICIAL" -o "$PMA_TMP"
  fi
fi
mkdir -p /var/www
rm -rf /var/www/phpmyadmin /var/www/phpmyadmin.new
mkdir -p /var/www/phpmyadmin.new
tar -xzf "$PMA_TMP" -C /var/www/phpmyadmin.new
PMA_DIR=$(find /var/www/phpmyadmin.new -mindepth 1 -maxdepth 1 -type d | head -n1)
[[ -n ${PMA_DIR:-} ]] || fail 'phpMyAdmin archive extraction failed.'
rm -rf /var/www/phpmyadmin
mv "$PMA_DIR" /var/www/phpmyadmin
rm -rf /var/www/phpmyadmin.new
cp -a /var/www/phpmyadmin/config.sample.inc.php /var/www/phpmyadmin/config.inc.php
BLOWFISH=$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9!@#$%^&*()_+=' | head -c 32)
python3 - /var/www/phpmyadmin/config.inc.php "$BLOWFISH" <<'PY'
import sys,re
p,key=sys.argv[1:]
s=open(p,encoding='utf-8').read()
line="$cfg['blowfish_secret'] = '"+key.replace("'","\\'")+"';"
if "$cfg['blowfish_secret']" in s:
    s=re.sub(r"\$cfg\['blowfish_secret'\]\s*=\s*'.*?';",line,s,count=1)
else:
    s=line+'\n'+s
open(p,'w',encoding='utf-8').write(s)
PY
mkdir -p /var/www/phpmyadmin/tmp
chown -R www-data:www-data /var/www/phpmyadmin
chmod 0770 /var/www/phpmyadmin/tmp
find /var/www/phpmyadmin -type d -exec chmod 0755 {} +
find /var/www/phpmyadmin -type f -exec chmod 0644 {} +

# -----------------------------------------------------------------------------
# THEX app
# -----------------------------------------------------------------------------
log 'Installing THEX application files...'
mkdir -p "$PANEL" "$AGENT" /run/thex "$WWW"
cp -a "$ROOT/panel/." "$PANEL/"
cp -a "$ROOT/agent/." "$AGENT/"
id thex >/dev/null 2>&1 || useradd --system --home "$THEX" --shell /usr/sbin/nologin thex
SECRET=$(openssl rand -hex 32)
ADMIN_PASS=${THEX_ADMIN_PASSWORD:-$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 20)}
ADMIN_HASH=$(THEX_PASS="$ADMIN_PASS" python3 - <<'PY'
import os, bcrypt
p=os.environ["THEX_PASS"].encode()
print(bcrypt.hashpw(p, bcrypt.gensalt(rounds=12)).decode())
PY
)
cat > "$PANEL/config.php" <<PHP
<?php
return [
 'version'=>'$THEX_VERSION',
 'secret'=>'$SECRET',
 'admin_user'=>'admin',
 'admin_password_hash'=>'$ADMIN_HASH',
 'php_version'=>'7.4',
];
PHP
chown -R thex:thex "$THEX"
usermod -aG thex nobody 2>/dev/null || true
chmod 755 "$THEX" "$PANEL"
chmod 640 "$PANEL/config.php"

# -----------------------------------------------------------------------------
# Core services
# -----------------------------------------------------------------------------
systemctl enable --now mariadb
systemctl enable --now redis-server
systemctl enable --now fail2ban
mysql -e "DELETE FROM mysql.user WHERE User='';" || true
mysql -e "DROP DATABASE IF EXISTS test;" || true
mysql -e "FLUSH PRIVILEGES;" || true

# Privileged agent — Python 3 root service. PHP itself remains LSPHP 7.4
# and is executed only by OpenLiteSpeed.
cat > /opt/thex/agent/agent.py <<'PY'
#!/usr/bin/env python3
import os, socket, json, subprocess, shutil, re, stat, pwd, grp, pathlib, tempfile

SOCK="/run/thex/agent.sock"
WWW="/var/www"
OLS="/usr/local/lsws"
PANEL_DOMAIN="panel.thex.cam"

def ok(**kw): return {"ok": True, **kw}
def err(msg): return {"ok": False, "error": msg}

def run(cmd, cwd=None):
    p=subprocess.run(cmd, shell=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=cwd)
    return p.returncode, p.stdout

def valid_domain(d):
    return bool(re.match(r"^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$", d, re.I))

def safe_name(s): return re.sub(r"[^A-Za-z0-9_]", "", s)

def safe_www(rel):
    rel=rel.replace("\x00","")
    base=os.path.realpath(WWW)
    target=os.path.realpath(os.path.join(WWW, rel))
    if target != base and not target.startswith(base + os.sep):
        raise ValueError("Invalid path")
    return target

def reload_ols():
    rc,out=run("/usr/local/lsws/bin/lswsctrl reload")
    if rc: raise RuntimeError(out.strip() or "OpenLiteSpeed reload failed")

def ensure_map(vhost, domains, listener="Default"):
    conf=pathlib.Path(OLS)/"conf"/"httpd_config.conf"
    s=conf.read_text(errors="ignore")
    for d in domains:
        if re.search(r"(?m)^\s*map\s+"+re.escape(vhost)+r"\s+.*\b"+re.escape(d)+r"\b", s):
            continue
        # Add mapping to the requested listener. If not found, fail loudly.
        pat=r"(?s)(listener\s+"+re.escape(listener)+r"\s*\{.*?)(\n\})"
        m=re.search(pat,s)
        if not m: raise RuntimeError(f"Listener {listener} not found")
        block=m.group(1)
        line=f"  map {vhost} {d}"
        wildcard=re.search(r"(?m)^\s*map\s+Example\s+\*\s*$", block)
        if wildcard:
            pos=wildcard.start()
            block=block[:pos]+line+"\n"+block[pos:]
        else:
            block += "\n"+line
        s=s[:m.start(1)]+block+s[m.end(1):]
    conf.write_text(s)

def create_vhost(domain, root):
    vhdir=pathlib.Path(OLS)/"conf"/"vhosts"/domain
    vhdir.mkdir(parents=True, exist_ok=True)
    (vhdir/"vhconf.conf").write_text(
        f"""docRoot {root}/public_html
vhDomain {domain}
vhAliases www.{domain}
enableGzip 1
index {{
  useServer 0
  indexFiles index.php,index.html,index.htm
}}
context / {{
  location {root}/public_html
  allowBrowse 0
}}
context /phpmyadmin/ {{
  location /var/www/phpmyadmin/
  allowBrowse 0
}}
"""
    )
    main=pathlib.Path(OLS)/"conf"/"httpd_config.conf"
    s=main.read_text(errors="ignore")
    if not re.search(r"(?m)^\s*virtualhost\s+"+re.escape(domain)+r"\s*\{", s):
        s += f"""
virtualhost {domain} {{
  vhRoot {root}
  configFile $SERVER_ROOT/conf/vhosts/{domain}/vhconf.conf
  allowSymbolLink 1
  enableScript 1
  restrained 1
  setUIDMode 0
}}
"""
        main.write_text(s)
    ensure_map(domain, [domain, "www."+domain], "Default")

def set_panel_vhost():
    root="/var/www/thex-panel"
    pathlib.Path(root+"/public_html").mkdir(parents=True, exist_ok=True)
    create_vhost(PANEL_DOMAIN, root)

def issue_ssl(domain):
    root=f"{WWW}/{domain}/public_html"
    if not os.path.isdir(root): raise RuntimeError("Create the domain first")
    rc,out=run("certbot certonly --webroot -w %s -d %s --non-interactive --agree-tos --register-unsafely-without-email" %
               (shlex_quote(root), shlex_quote(domain)))
    if rc: raise RuntimeError(out.strip())
    vh=pathlib.Path(OLS)/"conf"/"vhosts"/domain/"vhconf.conf"
    txt=vh.read_text(errors="ignore")
    ssl=f"""
vhssl {{
  keyFile /etc/letsencrypt/live/{domain}/privkey.pem
  certFile /etc/letsencrypt/live/{domain}/fullchain.pem
  certChain 1
}}
"""
    if "vhssl" not in txt: vh.write_text(txt+ssl)
    # Ensure an HTTPS listener exists and maps the vhost.
    conf=pathlib.Path(OLS)/"conf"/"httpd_config.conf"
    s=conf.read_text(errors="ignore")
    if not re.search(r"(?m)^\s*listener\s+HTTPS\s*\{", s):
        s += f"""
listener HTTPS {{
  address *:443
  secure 1
  keyFile /etc/letsencrypt/live/{domain}/privkey.pem
  certFile /etc/letsencrypt/live/{domain}/fullchain.pem
  certChain 1
  map {domain} {domain}
  map {domain} www.{domain}
}}
"""
    else:
        # Add mapping if needed.
        pat=r"(?s)(listener\s+HTTPS\s*\{.*?)(\n\})"
        m=re.search(pat,s)
        if m:
            block=m.group(1)
            for d in (domain,"www."+domain):
                if not re.search(r"\b"+re.escape(d)+r"\b",block):
                    block += f"\n  map {domain} {d}"
            s=s[:m.start(1)]+block+s[m.end(1):]
    conf.write_text(s)
    reload_ols()
    return ok(message=f"SSL certificate issued for {domain}", cert=f"/etc/letsencrypt/live/{domain}/fullchain.pem")

def shlex_quote(x):
    import shlex
    return shlex.quote(x)

def handle(a,d):
    if a=="domain_create":
        domain=d.get("domain","").strip().lower()
        if not valid_domain(domain): raise ValueError("Invalid domain")
        root=f"{WWW}/{domain}"
        pathlib.Path(root+"/public_html").mkdir(parents=True,exist_ok=True)
        idx=pathlib.Path(root+"/public_html/index.php")
        if not idx.exists(): idx.write_text("<?php echo 'THEX site: %s';" % domain.replace("'","\\'"))
        create_vhost(domain,root); reload_ols()
        return ok(message=f"Domain {domain} created",root=root)
    if a=="domain_delete":
        domain=d.get("domain","").strip().lower()
        if not valid_domain(domain): raise ValueError("Invalid domain")
        root=os.path.realpath(f"{WWW}/{domain}")
        base=os.path.realpath(WWW)
        if root.startswith(base+os.sep): shutil.rmtree(root,ignore_errors=True)
        vh=pathlib.Path(OLS)/"conf"/"vhosts"/domain
        shutil.rmtree(vh,ignore_errors=True)
        reload_ols()
        return ok(message=f"Domain {domain} deleted")
    if a=="db_create":
        name=safe_name(d.get("name","")); user=safe_name(d.get("user","")); pw=d.get("pass","")
        if not name or not user or len(pw)<8: raise ValueError("Invalid database data")
        rc,out=run("mysql -NBe %s" % shlex_quote(
            f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; "
            f"CREATE USER IF NOT EXISTS '{user}'@'localhost' IDENTIFIED BY '{pw.replace(chr(39), chr(92)+chr(39))}'; "
            f"ALTER USER '{user}'@'localhost' IDENTIFIED BY '{pw.replace(chr(39), chr(92)+chr(39))}'; "
            f"GRANT ALL PRIVILEGES ON `{name}`.* TO '{user}'@'localhost'; FLUSH PRIVILEGES;"
        ))
        if rc: raise RuntimeError(out.strip())
        return ok(message=f"Database {name} created",name=name,user=user)
    if a=="db_list":
        rc,out=run("mysql -NBe \"SHOW DATABASES;\"")
        if rc: raise RuntimeError(out.strip())
        system={"information_schema","mysql","performance_schema","sys","phpmyadmin"}
        names=[x.strip() for x in out.splitlines() if x.strip() and x.strip() not in system]
        return ok(databases=names)
    if a=="db_delete":
        name=safe_name(d.get("name",""))
        if not name: raise ValueError("Invalid database")
        rc,out=run("mysql -NBe %s" % shlex_quote(f"DROP DATABASE IF EXISTS `{name}`;"))
        if rc: raise RuntimeError(out.strip())
        return ok(message=f"Database {name} deleted")
    if a=="ssl_issue": return issue_ssl(d.get("domain","").strip().lower())
    if a=="service":
        service=d.get("service",""); op=d.get("op","restart")
        allowed={"lshttpd","mariadb","redis-server","fail2ban","thex-agent"}
        if service not in allowed or op not in {"start","stop","restart","reload"}: raise ValueError("Service operation not allowed")
        rc,out=run(f"systemctl {op} {shlex_quote(service)}")
        return ok(message=f"Service {service} {op}") if rc==0 else err(out.strip())
    if a=="port":
        port=int(d.get("port",0)); op=d.get("op","allow")
        if not 1<=port<=65535 or op not in {"allow","delete"}: raise ValueError("Invalid port")
        cmd=f"ufw {'allow' if op=='allow' else 'delete allow'} {port}/tcp"
        rc,out=run(cmd); return ok(message=f"Port {port} {op}") if rc==0 else err(out.strip())
    if a=="file_list":
        target=safe_www(d.get("path",""))
        items=[]
        for n in sorted(os.listdir(target)):
            if n in (".",".."): continue
            p=os.path.join(target,n)
            items.append({"name":n,"type":"dir" if os.path.isdir(p) else "file","size":os.path.getsize(p) if os.path.isfile(p) else 0,"mtime":int(os.path.getmtime(p))})
        return ok(path=os.path.relpath(target,WWW) if os.path.relpath(target,WWW)!="." else "/",items=items)
    if a=="file_read":
        target=safe_www(d.get("path",""))
        if not os.path.isfile(target) or os.path.getsize(target)>10*1024*1024: raise ValueError("Invalid or oversized file")
        return ok(path=os.path.relpath(target,WWW),content=open(target,encoding="utf-8",errors="replace").read())
    if a=="file_write":
        target=safe_www(d.get("path",""))
        if not os.path.isfile(target) or os.path.getsize(target)>10*1024*1024: raise ValueError("Invalid file")
        open(target,"w",encoding="utf-8").write(d.get("content","")); return ok(message="File saved")
    if a=="file_delete":
        target=safe_www(d.get("path",""))
        if target==os.path.realpath(WWW): raise ValueError("Invalid path")
        if os.path.isdir(target): shutil.rmtree(target)
        else: os.remove(target)
        return ok(message="Deleted")
    if a=="file_mkdir":
        target=safe_www(d.get("path",""))
        os.makedirs(target,exist_ok=False); return ok(message="Folder created")
    if a=="file_touch":
        target=safe_www(d.get("path","")); pathlib.Path(target).touch(exist_ok=False); return ok(message="File created")
    if a=="file_rename" or a=="file_move":
        a1=safe_www(d.get("from","")); a2=safe_www(d.get("to","")); os.rename(a1,a2); return ok(message="Renamed" if a=="file_rename" else "Moved")
    if a=="file_copy":
        a1=safe_www(d.get("from","")); a2=safe_www(d.get("to","")); 
        if os.path.isdir(a1): shutil.copytree(a1,a2)
        else: shutil.copy2(a1,a2)
        return ok(message="Copied")
    if a=="file_chmod":
        target=safe_www(d.get("path","")); mode=d.get("mode","0644")
        if not re.fullmatch(r"[0-7]{4}",mode): raise ValueError("Invalid chmod")
        os.chmod(target,int(mode,8)); return ok(message="Permissions updated")
    if a=="file_zip":
        target=safe_www(d.get("path","")); output=safe_www(d.get("output","archive.zip"))
        import zipfile
        with zipfile.ZipFile(output,"w",zipfile.ZIP_DEFLATED) as z:
            if os.path.isdir(target):
                for root,dirs,files in os.walk(target):
                    for f in files: z.write(os.path.join(root,f),os.path.relpath(os.path.join(root,f),os.path.dirname(target)))
            else: z.write(target,os.path.basename(target))
        return ok(message="Archive created")
    if a=="file_unzip":
        zp=safe_www(d.get("path","")); dest=safe_www(d.get("dest",""))
        import zipfile
        with zipfile.ZipFile(zp) as zz: zz.extractall(dest)
        return ok(message="Archive extracted")
    raise ValueError("Action not allowed")

def main():
    pathlib.Path("/run/thex").mkdir(parents=True,exist_ok=True)
    try: os.unlink(SOCK)
    except FileNotFoundError: pass
    srv=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); srv.bind(SOCK); os.chmod(SOCK,0o660); srv.listen(20)
    try:
        os.chown(SOCK,pwd.getpwnam("thex").pw_uid,grp.getgrnam("thex").gr_gid)
    except Exception: pass
    while True:
        c,_=srv.accept()
        try:
            line=c.recv(1024*1024).decode()
            req=json.loads(line); res=handle(req.get("action",""),req.get("data",{}))
        except Exception as e: res=err(str(e))
        c.sendall((json.dumps(res,separators=(",",":"))+"\n").encode()); c.close()
if __name__=="__main__": main()
PY
chmod 750 /opt/thex/agent/agent.py
chown root:root /opt/thex/agent/agent.py

cat > /etc/systemd/system/thex-agent.service <<EOF
[Unit]
Description=THEX Privileged Agent
After=network.target mariadb.service redis-server.service lshttpd.service

[Service]
Type=simple
User=root
Group=root
ExecStartPre=/usr/bin/usermod -aG thex nobody
ExecStart=/usr/bin/python3 /opt/thex/agent/agent.py
Restart=always
RestartSec=2
RuntimeDirectory=thex
RuntimeDirectoryMode=0770
ProtectSystem=full
ProtectHome=false
ReadWritePaths=/var/www /opt/thex /etc/letsencrypt $OLS /etc/ufw /etc/fail2ban /etc/systemd/system /run/thex

[Install]
WantedBy=multi-user.target
EOF

# Serve the panel through OpenLiteSpeed + LSPHP 7.4. No PHP built-in server.
mkdir -p /var/www/thex-panel/public_html
cp -a "$PANEL/." /var/www/thex-panel/public_html/
chown -R thex:thex /var/www/thex-panel
chmod 755 /var/www/thex-panel /var/www/thex-panel/public_html
find /var/www/thex-panel/public_html -type d -exec chmod 755 {} +
find /var/www/thex-panel/public_html -type f -exec chmod 644 {} +
chmod 640 /var/www/thex-panel/public_html/config.php

cat > /etc/systemd/system/thex-panel.service <<EOF
[Unit]
Description=THEX Panel is served by OpenLiteSpeed
After=network.target thex-agent.service
Requires=thex-agent.service

[Service]
Type=oneshot
ExecStart=/bin/true
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
EOF

cat > /etc/systemd/system/thex-cert-renew.service <<EOF
[Unit]
Description=THEX certificate renewal
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet
EOF
cat > /etc/systemd/system/thex-cert-renew.timer <<EOF
[Unit]
Description=THEX certificate renewal timer
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
[Install]
WantedBy=timers.target
EOF

ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable

# Configure the panel vhost and HTTP listener.
# Normalize the stock HTTP listener to port 80 and make the panel mapping
# take precedence over the stock wildcard Example mapping.
python3 - <<'PY'
from pathlib import Path
import re
p=Path('/usr/local/lsws/conf/httpd_config.conf')
s=p.read_text(errors='ignore')
m=re.search(r'(listener\s+Default\s*\{)(.*?)(\n\})',s,re.S)
if not m:
    raise SystemExit("Default listener not found")
body=m.group(2)
body=re.sub(r'address\s+\*:8088', 'address *:80', body)
# remove existing THEX/Example maps, then add only the panel mapping
body=re.sub(r'(?m)^\s*map\s+panel\.thex\.cam\s+.*$', '', body)
body=re.sub(r'(?m)^\s*map\s+Example\s+\*\s*$', '', body)
body += '\n  map panel.thex.cam panel.thex.cam\n'
s=s[:m.start(2)]+body+s[m.end(2):]
p.write_text(s)
PY
set_panel_vhost
"$OLS/bin/openlitespeed" -t || fail 'OpenLiteSpeed final configuration test failed.'
systemctl daemon-reload
systemctl enable --now thex-agent
systemctl enable thex-panel
systemctl start thex-panel
systemctl enable --now thex-cert-renew.timer

# Copy panel files after config.php has been generated below.
# -----------------------------------------------------------------------------
# Final verification — never invoke LSPHP as a CLI.
# -----------------------------------------------------------------------------
LSPHP_PKG_VER="$(dpkg-query -W -f='${Version}' lsphp74 2>/dev/null || true)"
[[ "$LSPHP_PKG_VER" == 7.4.* ]] || fail "Final LSPHP package check failed: ${LSPHP_PKG_VER:-missing}"
[[ -x "$LSPHP" ]] || fail "Final LSPHP binary check failed."
systemctl is-active --quiet mariadb || fail 'MariaDB is not running.'
systemctl is-active --quiet redis-server || fail 'Redis is not running.'
systemctl is-active --quiet thex-agent || fail 'THEX agent failed.'
systemctl is-active --quiet "$OLS_UNIT" || fail 'OpenLiteSpeed failed.'
test -f /var/www/thex-panel/public_html/index.php || fail 'THEX panel files are missing.'
cat > /var/www/thex-panel/public_html/__thex_php_check.php <<'PHPX'
<?php echo PHP_VERSION;
PHPX
chown thex:thex /var/www/thex-panel/public_html/__thex_php_check.php
chmod 0644 /var/www/thex-panel/public_html/__thex_php_check.php
HTTP_PHP=$(curl -fsS --max-time 15 -H 'Host: panel.thex.cam' http://127.0.0.1/__thex_php_check.php || true)
rm -f /var/www/thex-panel/public_html/__thex_php_check.php
[[ "$HTTP_PHP" == 7.4.* ]] || fail "OpenLiteSpeed PHP runtime check failed. Got: ${HTTP_PHP:-no response}"


log "Final PHP package: lsphp74 $LSPHP_PKG_VER"
log "PHP runtime: LSPHP 7.4 (served by OpenLiteSpeed)"
log 'Services:'
systemctl is-active "$OLS_UNIT" mariadb redis-server thex-agent

IP=$(hostname -I | awk '{print $1}')
echo
echo '=================================================='
echo " THEX v$THEX_VERSION INSTALLATION COMPLETE"
echo '=================================================='
echo "Panel backend : http://127.0.0.1:2087"
echo 'Panel user    : admin'
echo "Panel password: $ADMIN_PASS"
echo "Server IP     : $IP"
echo "PHP runtime   : LSPHP 7.4 (LSAPI)"
echo "Install log   : $LOG"
echo '=================================================='
