#!/usr/bin/env python3
"""
Daffa Upgrade OJS
Asisten upgrade Open Journal Systems (OJS/OMP/OPS) lewat SSH.

Berjalan di komputer Anda sendiri (http://127.0.0.1), menyambung ke server
dengan SSH, lalu menjalankan prosedur upgrade resmi PKP:
pindai -> deteksi -> backup -> unduh -> upgrade -> verifikasi (+ rollback).

Kredensial hanya disimpan di memori selama aplikasi berjalan dan tidak
pernah ditulis ke disk.
"""
import base64
import configparser
import hashlib
import os
import posixpath
import re
import secrets
import shlex
import socket
import sys
import threading
import time
import uuid
import webbrowser
from datetime import datetime

try:
    import paramiko
    from flask import Flask, abort, jsonify, request
except ImportError:
    print("Paket belum terpasang. Jalankan dulu:  pip install -r requirements.txt")
    sys.exit(1)

APP_DIR = os.path.dirname(os.path.abspath(__file__))
KNOWN_HOSTS = os.path.join(os.path.expanduser("~"), ".daffa_known_hosts")
TOKEN = secrets.token_urlsafe(32)
STAGING = "daffa-staging"      # relatif terhadap $HOME di server
BACKUP_ROOT = "daffa-backup"   # relatif terhadap $HOME di server
DOWNLOAD_URL = os.environ.get("DAFFA_DOWNLOAD_URL", "https://pkp.sfu.ca/ojs/download/ojs-{ver}.tar.gz")

# Perkiraan versi PHP minimum per seri OJS (cek README rilis untuk kepastian)
PHP_MIN = {"3.3": (7, 3), "3.4": (8, 0), "3.5": (8, 2)}

PATH_RE = re.compile(r"^/[A-Za-z0-9._/@+~-]*$")
VER_RE = re.compile(r"^\d+\.\d+\.\d+-\d+$")
BIN_RE = re.compile(r"^[A-Za-z0-9_./-]+$")
# Isi folder bawaan paket OJS yang tidak perlu ikut disalin dari folder lama
OJS_OWN = {"config.inc.php", "public", "cache"}


class Bad(Exception):
    """Kesalahan input / kondisi yang bisa diperbaiki pengguna."""


# ---------------------------------------------------------------- sesi SSH
class Session:
    def __init__(self):
        self.client = None
        self.host = ""
        self.user = ""
        self.home = ""
        self.lock = threading.Lock()   # satu proses remote pada satu waktu
        self.backups = {}              # path instalasi -> info backup
        self.swaps = {}                # path instalasi -> info tukar folder

    def require(self):
        if not self.client or not self.client.get_transport() or not self.client.get_transport().is_active():
            self.client = None
            raise Bad("Belum tersambung. Isi quickconnect lalu klik Sambungkan.")
        return self.client


S = Session()


def fingerprint(key):
    digest = hashlib.sha256(key.asbytes()).digest()
    return "SHA256:" + base64.b64encode(digest).decode().rstrip("=")


class UnknownHost(Exception):
    def __init__(self, key):
        super().__init__("unknown host key")
        self.key = key


class AskPolicy(paramiko.MissingHostKeyPolicy):
    """Kunci host baru harus dikonfirmasi pengguna (cegah server palsu)."""

    def __init__(self, accept_fp=None):
        self.accept_fp = accept_fp

    def missing_host_key(self, client, hostname, key):
        if self.accept_fp and self.accept_fp == fingerprint(key):
            client.get_host_keys().add(hostname, key.get_name(), key)
            client.save_host_keys(KNOWN_HOSTS)
            return
        raise UnknownHost(key)


# ---------------------------------------------------------------- utilitas
def q(s):
    return shlex.quote(s)


def clean_path(p, label):
    p = (p or "").strip()
    if len(p) > 1:
        p = p.rstrip("/")
    if not p or not PATH_RE.match(p) or ".." in p.split("/"):
        raise Bad(f"{label} tidak valid. Gunakan path absolut, mis. /home/user/public_html/ojs")
    return p


def clean_ver(v):
    v = (v or "").strip()
    if not VER_RE.match(v):
        raise Bad("Versi tujuan tidak valid. Format: 3.5.0-5")
    return v


def clean_bin(b):
    b = (b or "php").strip() or "php"
    if not BIN_RE.match(b):
        raise Bad("Biner PHP tidak valid. Contoh: php atau /opt/cpanel/ea-php82/root/usr/bin/php")
    return b


def sftp_read(path):
    sf = S.require().open_sftp()
    try:
        with sf.open(path, "r") as f:
            return f.read().decode("utf-8", "replace")
    finally:
        sf.close()


def sftp_write_private(path, text):
    """Tulis berkas dengan izin 600 (izin diset sebelum isi ditulis)."""
    sf = S.require().open_sftp()
    try:
        f = sf.open(path, "w")
        sf.chmod(path, 0o600)
        f.write(text)
        f.close()
    finally:
        sf.close()


def remote_exists(path):
    sf = S.require().open_sftp()
    try:
        sf.stat(path)
        return True
    except IOError:
        return False
    finally:
        sf.close()


def read_release(install_path):
    txt = sftp_read(install_path + "/dbscripts/xml/version.xml")
    rel = re.search(r"<release>\s*([^<\s]+)\s*</release>", txt)
    app = re.search(r"<application>\s*([^<\s]+)\s*</application>", txt)
    return (rel.group(1) if rel else "?"), (app.group(1) if app else "ojs2")


def read_config(install_path):
    raw = sftp_read(install_path + "/config.inc.php")
    cp = configparser.ConfigParser(strict=False, interpolation=None,
                                   comment_prefixes=(";", "#"), inline_comment_prefixes=None)
    # Baris pembuka "; <?php exit(); ?>" aman: diawali ';'
    cp.read_string(raw)

    def get(sec, key, default=""):
        try:
            v = cp.get(sec, key)
        except (configparser.NoSectionError, configparser.NoOptionError):
            return default
        v = v.strip()
        if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
            v = v[1:-1]
        return v

    return {
        "installed": get("general", "installed", "Off"),
        "base_url": get("general", "base_url"),
        "files_dir": get("files", "files_dir"),
        "db": {
            "driver": get("database", "driver", "mysqli"),
            "host": get("database", "host", "localhost"),
            "port": get("database", "port", ""),
            "user": get("database", "username"),
            "password": get("database", "password"),
            "name": get("database", "name"),
        },
    }


def php_ok(php_line, target_ver):
    m = re.search(r"PHP (\d+)\.(\d+)", php_line or "")
    series = ".".join(target_ver.split(".")[:2])
    need = PHP_MIN.get(series)
    if not m:
        return None, "Versi PHP tidak terbaca."
    have = (int(m.group(1)), int(m.group(2)))
    if need and have < need:
        return False, f"PHP {have[0]}.{have[1]} terlalu lama untuk OJS {series} (butuh {need[0]}.{need[1]}+). Ganti PHP di cPanel (MultiPHP) atau isi Biner PHP yang sesuai."
    return True, f"PHP {have[0]}.{have[1]} cocok untuk OJS {series}."


# ---------------------------------------------------------------- jobs
class Job:
    def __init__(self, secrets_to_mask=()):
        self.id = uuid.uuid4().hex[:12]
        self.events = []
        self.finished = False
        self.ok = None
        self.data = {}
        self.mask = [s for s in secrets_to_mask if s and len(s) >= 3]

    def emit(self, kind, text):
        for s in self.mask:
            text = text.replace(s, "••••••")
        self.events.append({"k": kind, "t": text, "ts": time.strftime("%H:%M:%S")})

    def run_remote(self, script, display=None):
        """Jalankan skrip bash di server, alirkan output ke log. Kembalikan (kode, output)."""
        client = S.require()
        self.emit("cmd", display or script)
        chan = client.get_transport().open_session()
        chan.set_combine_stderr(True)
        chan.exec_command("bash -lc " + q(script))
        out, buf = [], b""
        while True:
            if chan.recv_ready():
                buf += chan.recv(8192)
                while b"\n" in buf:
                    line, buf = buf.split(b"\n", 1)
                    s = line.decode("utf-8", "replace").rstrip("\r")
                    out.append(s)
                    self.emit("out", s)
            elif chan.exit_status_ready():
                if not chan.recv_ready():
                    break
            else:
                time.sleep(0.05)
        if buf:
            s = buf.decode("utf-8", "replace")
            out.append(s)
            self.emit("out", s)
        code = chan.recv_exit_status()
        chan.close()
        return code, "\n".join(out)


JOBS = {}


def start_job(plan):
    if not S.lock.acquire(blocking=False):
        raise Bad("Masih ada proses yang berjalan. Tunggu sampai selesai.")
    job = Job(plan.get("mask", ()))
    JOBS[job.id] = job

    def worker():
        try:
            job.emit("sys", "▶ " + plan["title"])
            if plan.get("before"):
                plan["before"](job)
            code, out = (0, "")
            if plan.get("script"):
                code, out = job.run_remote(plan["script"], plan.get("display"))
            if code != 0:
                job.emit("err", f"Gagal (kode keluar {code}). {plan.get('fail_hint', '')}".strip())
                job.ok = False
            else:
                job.ok = True
                if plan.get("after"):
                    res = plan["after"](job, out)
                    if res is False:
                        job.ok = False
                if job.ok:
                    job.emit("ok", "✓ " + plan.get("done", "Selesai."))
        except Bad as e:
            job.emit("err", str(e))
            job.ok = False
        except Exception as e:  # noqa: BLE001
            job.emit("err", f"Kesalahan: {e}")
            job.ok = False
        finally:
            if plan.get("cleanup"):
                try:
                    plan["cleanup"]()
                except Exception:  # noqa: BLE001
                    pass
            job.finished = True
            S.lock.release()

    threading.Thread(target=worker, daemon=True).start()
    return job


# ---------------------------------------------------------------- rencana langkah
def p_scan(d, preview=False):
    root = clean_path(d.get("root") or S.home or "/home", "Folder pindai")
    script = (
        f"find {q(root)} -maxdepth 7 -type f -path '*/dbscripts/xml/version.xml' "
        f"-not -path '*/lib/pkp/*' -not -path '*daffa-old*' -not -path '*daffa-failed*' "
        f"-not -path '*/{STAGING}/*' 2>/dev/null | sort"
    )

    def after(job, out):
        found = []
        for line in out.splitlines():
            line = line.strip()
            if not line.endswith("/dbscripts/xml/version.xml"):
                continue
            path = line[: -len("/dbscripts/xml/version.xml")]
            try:
                rel, app = read_release(path)
            except Exception:  # noqa: BLE001
                rel, app = "?", "?"
            found.append({"path": path, "release": rel, "app": app})
        job.data["installs"] = found
        if not found:
            job.emit("warn", f"Tidak ada instalasi OJS ditemukan di {root}. Coba ubah Folder pindai.")
        else:
            job.emit("sys", f"{len(found)} instalasi ditemukan. Klik salah satu untuk dipilih.")

    return {"title": "Pindai instalasi OJS", "script": script, "after": after,
            "done": "Pemindaian selesai."}


def p_detect(d, preview=False):
    path = clean_path(d.get("path"), "Instalasi")
    ver = clean_ver(d.get("version"))
    php = clean_bin(d.get("php"))
    script = (f"cd {q(path)} && {php} -r 'echo \"PHP \".PHP_VERSION.PHP_EOL;' "
              f"&& df -Ph {q(path)} | tail -n1")

    def after(job, out):
        rel, app = read_release(path)
        cfg = read_config(path)
        lines = [ln for ln in out.splitlines() if ln.strip()]
        php_line = next((ln for ln in lines if re.match(r"^PHP \d", ln)), "")
        ok, msg = php_ok(php_line, ver)
        files_dir = cfg["files_dir"]
        inside = bool(files_dir) and (files_dir + "/").startswith(path + "/")
        df = lines[-1].split() if lines else []
        info = {
            "release": rel, "app": app, "php": php_line, "php_ok": ok,
            "db_name": cfg["db"]["name"], "db_driver": cfg["db"]["driver"],
            "files_dir": files_dir, "files_inside": inside, "base_url": cfg["base_url"],
            "disk_free": df[3] if len(df) > 3 else "?",
        }
        job.data["info"] = info
        job.emit("sys", f"Versi terpasang: {rel}  →  tujuan: {ver}")
        job.emit("ok" if ok else "warn", msg)
        job.emit("sys", f"Database: {cfg['db']['name']} ({cfg['db']['driver']})  ·  files_dir: {files_dir or '(tidak diisi)'}")
        job.emit("sys", f"Sisa ruang disk: {info['disk_free']}")
        if cfg["installed"].lower() != "on":
            job.emit("warn", "config.inc.php: installed bukan On. Pastikan ini instalasi yang aktif.")
        if inside:
            job.emit("warn", "files_dir berada di dalam folder OJS. Aplikasi akan memindahkannya dengan aman saat upgrade, tapi sebaiknya files_dir di luar public_html.")
        if rel.startswith("2."):
            job.emit("warn", "Instalasi OJS 2.x: upgrade sebaiknya bertahap (2.4.8 → 3.3 → versi baru). Baca catatan rilis PKP.")
        if ok is False:
            return False
        return True

    return {"title": "Deteksi versi & kesiapan server", "script": script, "after": after,
            "done": "Server siap diupgrade."}


def _db_files(cfg, tmpname):
    """Isi berkas kredensial DB sementara + perintah dump/restore."""
    db = cfg["db"]
    home = S.home
    if "postgres" in db["driver"].lower():
        path = f"{home}/.daffa-{tmpname}.pgpass"
        port = db["port"] or "5432"
        content = f"{db['host']}:{port}:{db['name']}:{db['user']}:{db['password']}\n"
        dump = (f"PGPASSFILE={q(path)} pg_dump -h {q(db['host'])} -p {q(port)} -U {q(db['user'])} "
                f"-Fc -f \"$B/db.dump\" {q(db['name'])}")
        restore = (f"PGPASSFILE={q(path)} pg_restore --clean --if-exists -h {q(db['host'])} -p {q(port)} "
                   f"-U {q(db['user'])} -d {q(db['name'])} \"$B/db.dump\"")
        return path, content, dump, restore, "db.dump"
    path = f"{home}/.daffa-{tmpname}.cnf"

    def opt(v):
        v = v.replace("\\", "\\\\")
        return f"'{v}'" if '"' in v else f'"{v}"'

    lines = ["[client]", f"user={opt(db['user'])}", f"password={opt(db['password'])}", f"host={opt(db['host'])}"]
    if db["port"]:
        lines.append(f"port={db['port']}")
    content = "\n".join(lines) + "\n"
    cnf = q(path)
    dump = (f"mysqldump --defaults-extra-file={cnf} --single-transaction --no-tablespaces "
            f"--default-character-set=utf8mb4 {q(db['name'])} | gzip > \"$B/db.sql.gz\"")
    restore = (
        f"DUMPT=$(gunzip -c \"$B/db.sql.gz\" | grep -oE '^CREATE TABLE `[^`]+`' | cut -d'`' -f2 | sort)\n"
        f"CURT=$(mysql --defaults-extra-file={cnf} -N -e 'SHOW TABLES' {q(db['name'])} | sort)\n"
        f"for t in $(comm -13 <(echo \"$DUMPT\") <(echo \"$CURT\")); do\n"
        f"  echo \"  hapus tabel baru: $t\"\n"
        f"  mysql --defaults-extra-file={cnf} -e \"SET FOREIGN_KEY_CHECKS=0; DROP TABLE \\`$t\\`\" {q(db['name'])}\n"
        f"done\n"
        f"gunzip -c \"$B/db.sql.gz\" | mysql --defaults-extra-file={cnf} {q(db['name'])}"
    )
    return path, content, dump, restore, "db.sql.gz"


def p_backup(d, preview=False):
    path = clean_path(d.get("path"), "Instalasi")
    cfg = read_config(path)
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    name = posixpath.basename(path) or "ojs"
    bdir = f"{S.home}/{BACKUP_ROOT}/{name}-{stamp}"
    if not cfg["db"]["name"]:
        raise Bad("Nama database tidak ditemukan di config.inc.php.")
    credfile, content, dump, _restore, dumpname = _db_files(cfg, "bk" + stamp)
    files_dir = cfg["files_dir"]
    files_sep = bool(files_dir) and not (files_dir + "/").startswith(path + "/")
    parent, base = posixpath.split(path)
    script = "\n".join(filter(None, [
        "set -eo pipefail",
        "umask 077",
        f"trap 'rm -f {q(credfile)}' EXIT",
        f"B={q(bdir)}",
        "mkdir -p \"$B\"",
        "echo '[1/3] Backup database...'",
        dump,
        "echo '[2/3] Arsip kode aplikasi...'",
        f"tar czf \"$B/app.tar.gz\" -C {q(parent)} {q(base)}",
        ("echo '[3/3] Arsip files_dir...'\n"
         f"tar czf \"$B/files.tar.gz\" -C {q(posixpath.dirname(files_dir))} {q(posixpath.basename(files_dir))}")
        if files_sep else "echo '[3/3] files_dir ada di dalam folder OJS, sudah ikut arsip kode.'",
        "echo 'Isi backup:'",
        "ls -lh \"$B\"",
        f"test -s \"$B/{dumpname}\"",
        "test -s \"$B/app.tar.gz\"",
    ]))
    display = script.replace(credfile, "~/.daffa-…(kredensial DB sementara, izin 600, dihapus otomatis)")

    def before(job):
        sftp_write_private(credfile, content)

    def after(job, out):
        S.backups[path] = {"dir": bdir, "stamp": stamp, "dumpname": dumpname}
        job.data["backup_dir"] = bdir
        job.emit("sys", f"Backup tersimpan di {bdir}")
        return True

    return {"title": "Backup database, kode, dan berkas", "script": script, "display": display,
            "before": before, "after": after, "mask": [cfg["db"]["password"]],
            "cleanup": lambda: _rm_quiet(credfile),
            "fail_hint": "Periksa kredensial DB di config.inc.php dan ruang disk.",
            "done": "Backup selesai dan diverifikasi."}


def _rm_quiet(path):
    try:
        sf = S.require().open_sftp()
        try:
            sf.remove(path)
        finally:
            sf.close()
    except Exception:  # noqa: BLE001
        pass


def p_download(d, preview=False):
    ver = clean_ver(d.get("version"))
    url = DOWNLOAD_URL.format(ver=ver)
    stg = f"{S.home or '~'}/{STAGING}"
    tgz = f"ojs-{ver}.tar.gz"
    script = "\n".join([
        "set -e",
        f"mkdir -p {q(stg)} && cd {q(stg)}",
        f"if [ ! -s {q(tgz)} ]; then",
        f"  echo 'Mengunduh {url}'",
        f"  if command -v curl >/dev/null; then curl -fsSL --retry 2 -o {q(tgz)}.part {q(url)};"
        f" else wget -q -O {q(tgz)}.part {q(url)}; fi",
        f"  mv {q(tgz)}.part {q(tgz)}",
        "else echo 'Paket sudah ada, memakai yang tersimpan.'; fi",
        f"rm -rf {q('ojs-' + ver)}",
        f"tar xzf {q(tgz)}",
        f"test -f {q('ojs-' + ver)}/config.TEMPLATE.inc.php",
        f"echo \"Diekstrak ke {stg}/ojs-{ver}\"",
        f"du -sh {q('ojs-' + ver)}",
    ])
    return {"title": f"Unduh paket OJS {ver}", "script": script,
            "fail_hint": "Pastikan nomor versi ada di pkp.sfu.ca dan server bisa mengakses internet.",
            "done": "Paket siap dipasang."}


def p_upgrade(d, preview=False):
    path = clean_path(d.get("path"), "Instalasi")
    ver = clean_ver(d.get("version"))
    php = clean_bin(d.get("php"))
    if not preview and path not in S.backups:
        raise Bad("Jalankan langkah Backup untuk instalasi ini dulu. Upgrade tanpa backup tidak diizinkan.")
    cfg = read_config(path)
    files_dir = cfg["files_dir"]
    rel_files = ""
    if files_dir and (files_dir + "/").startswith(path + "/"):
        rel_files = posixpath.relpath(files_dir, path)
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    new = f"{S.home}/{STAGING}/ojs-{ver}"
    bak = f"{path}.daffa-old-{stamp}"
    keep = " ".join(q(x) for x in sorted(OJS_OWN | ({rel_files.split('/')[0]} if rel_files else set())))
    script = "\n".join(filter(None, [
        "set -eo pipefail",
        f"OLD={q(path)}; NEW={q(new)}; BAK={q(bak)}",
        "test -d \"$NEW\" || { echo 'Paket belum diunduh. Jalankan langkah Unduh dulu.'; exit 2; }",
        "test -f \"$OLD/config.inc.php\"",
        "echo '[1/5] Menyalin config.inc.php dan public/ ke paket baru...'",
        "cp -p \"$OLD/config.inc.php\" \"$NEW/config.inc.php\"",
        "mkdir -p \"$NEW/public\" && cp -a \"$OLD/public/.\" \"$NEW/public/\"",
        "echo '[2/5] Menyalin berkas tambahan (mis. .htaccess, .well-known)...'",
        f"KEEP=\" {keep} \"",
        "for e in \"$OLD\"/* \"$OLD\"/.[!.]*; do",
        "  [ -e \"$e\" ] || continue; n=$(basename \"$e\")",
        "  case \"$KEEP\" in *\" $n \"*) continue;; esac",
        "  if [ ! -e \"$NEW/$n\" ]; then echo \"  salin: $n\"; cp -a \"$e\" \"$NEW/$n\"; fi",
        "done",
        "for p in \"$OLD\"/plugins/*/*; do",
        "  [ -d \"$p\" ] || continue; r=${p#$OLD/}",
        "  [ -e \"$NEW/$r\" ] || echo \"  perhatian: plugin pihak ketiga $r tidak ikut — pasang versi yang cocok setelah upgrade\"",
        "done",
        "echo '[3/5] Menukar folder (situs sempat tidak tersedia beberapa saat)...'",
        "mv \"$OLD\" \"$BAK\"",
        "mv \"$NEW\" \"$OLD\"",
        "chmod --reference=\"$BAK\" \"$OLD\" 2>/dev/null || true",
        (f"mv \"$BAK\"/{q(rel_files)} \"$OLD\"/{q(rel_files)}" if rel_files else ""),
        "mkdir -p \"$OLD/cache/t_compile\" \"$OLD/cache/t_cache\" \"$OLD/cache/_db\"",
        "echo '[4/5] Menjalankan migrasi database (bisa beberapa menit)...'",
        "cd \"$OLD\"",
        f"{php} tools/upgrade.php check || true",
        f"{php} tools/upgrade.php upgrade",
        "echo '[5/5] Selesai.'",
    ]))

    def before(job):
        S.swaps[path] = {"bak": bak, "rel_files": rel_files, "stamp": stamp, "swapped": False}

    def after(job, out):
        S.swaps[path]["swapped"] = True
        if re.search(r"(?i)\b(error|exception)\b", out) and "Successfully upgraded" not in out:
            job.emit("warn", "Output upgrade berisi pesan error. Periksa log di atas; gunakan Rollback bila situs bermasalah.")
        job.data["old_dir"] = bak
        job.emit("sys", f"Folder lama disimpan sebagai {bak}")
        return True

    def cleanup():
        # Tandai swap bila mv sudah terjadi meski upgrade gagal, agar Rollback tersedia
        info = S.swaps.get(path)
        if info and not info["swapped"] and remote_exists(bak):
            info["swapped"] = True

    return {"title": f"Upgrade ke OJS {ver}", "script": script, "before": before, "after": after,
            "cleanup": cleanup,
            "fail_hint": "Folder lama dan backup aman. Klik Rollback untuk memulihkan.",
            "done": f"Upgrade ke OJS {ver} selesai."}


def p_verify(d, preview=False):
    path = clean_path(d.get("path"), "Instalasi")
    cfg = read_config(path)
    url = cfg["base_url"]
    script = "\n".join(filter(None, [
        f"cd {q(path)}",
        "rm -rf cache/t_compile/* cache/t_cache/* cache/_db/* cache/fc-* cache/*.php 2>/dev/null || true",
        "echo 'Cache dibersihkan.'",
        "grep -m1 -o '<release>[^<]*' dbscripts/xml/version.xml | cut -d'>' -f2 | sed 's/^/Versi sekarang: /'",
        (f"echo \"HTTP {url}: $(curl -s -o /dev/null -w '%{{http_code}}' -L --max-time 20 {q(url)})\"" if url else ""),
    ]))

    def after(job, out):
        m = re.search(r"HTTP .*: (\d{3})", out)
        if m:
            code = int(m.group(1))
            if code >= 500:
                job.emit("err", f"Situs merespons {code}. Periksa error log PHP; gunakan Rollback bila perlu.")
                return False
            job.emit("ok" if code < 400 else "warn", f"Situs merespons HTTP {code}.")
        job.emit("sys", "Cek manual: halaman About, login admin, daftar isu, plugin aktif.")
        return True

    return {"title": "Verifikasi & bersihkan cache", "script": script, "after": after,
            "done": "Verifikasi selesai. Simpan backup sampai situs terbukti normal."}


def p_rollback(d, preview=False):
    path = clean_path(d.get("path"), "Instalasi")
    sw = S.swaps.get(path)
    bk = S.backups.get(path)
    if not sw or not sw.get("swapped"):
        raise Bad("Belum ada upgrade yang bisa dibatalkan untuk instalasi ini di sesi ini.")
    if not bk:
        raise Bad("Data backup sesi ini tidak ditemukan.")
    cfg = read_config(sw["bak"])
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    credfile, content, _dump, restore, _ = _db_files(cfg, "rb" + stamp)
    rel = sw["rel_files"]
    script = "\n".join(filter(None, [
        "set -eo pipefail",
        "umask 077",
        f"trap 'rm -f {q(credfile)}' EXIT",
        f"OLD={q(path)}; BAK={q(sw['bak'])}; B={q(bk['dir'])}; FAIL={q(path + '.daffa-failed-' + stamp)}",
        "test -d \"$BAK\"",
        "echo '[1/2] Mengembalikan folder lama...'",
        (f"[ -e \"$OLD\"/{q(rel)} ] && mv \"$OLD\"/{q(rel)} \"$BAK\"/{q(rel)}" if rel else ""),
        "[ -e \"$OLD\" ] && mv \"$OLD\" \"$FAIL\"",
        "mv \"$BAK\" \"$OLD\"",
        "echo '[2/2] Memulihkan database dari backup...'",
        restore,
        "echo 'Folder hasil upgrade gagal disimpan di:' \"$FAIL\"",
    ]))
    display = script.replace(credfile, "~/.daffa-…(kredensial DB sementara)")

    def before(job):
        sftp_write_private(credfile, content)

    def after(job, out):
        S.swaps.pop(path, None)
        return True

    return {"title": "Rollback ke versi sebelumnya", "script": script, "display": display,
            "before": before, "after": after, "mask": [cfg["db"]["password"]],
            "cleanup": lambda: _rm_quiet(credfile),
            "done": "Rollback selesai. Situs kembali ke versi sebelum upgrade."}


PLANS = {"scan": p_scan, "detect": p_detect, "backup": p_backup, "download": p_download,
         "upgrade": p_upgrade, "verify": p_verify, "rollback": p_rollback}


# ---------------------------------------------------------------- web
app = Flask(__name__)


@app.before_request
def guard():
    host = (request.host or "").split(":")[0]
    if host not in ("127.0.0.1", "localhost"):
        abort(403)
    if request.path.startswith("/api/") and request.headers.get("X-Daffa-Token") != TOKEN:
        abort(403)


@app.after_request
def no_cache(resp):
    resp.headers["Cache-Control"] = "no-store"
    resp.headers["X-Frame-Options"] = "DENY"
    return resp


def err(msg, **extra):
    return jsonify(ok=False, error=msg, **extra)


@app.get("/")
def index():
    with open(os.path.join(APP_DIR, "ui", "index.html"), encoding="utf-8") as f:
        return f.read().replace("__DAFFA_TOKEN__", TOKEN)


@app.post("/api/connect")
def api_connect():
    d = request.get_json(force=True) or {}
    host = (d.get("host") or "").strip()
    user = (d.get("user") or "").strip()
    password = d.get("password") or None
    keyfile = (d.get("keyfile") or "").strip() or None
    try:
        port = int(d.get("port") or 22)
    except ValueError:
        return err("Port harus angka.")
    if not host or not user:
        return err("Isi Host dan Pengguna dulu.")
    if keyfile:
        keyfile = os.path.expanduser(keyfile)
        if not os.path.isfile(keyfile):
            return err(f"Berkas kunci SSH tidak ditemukan: {keyfile}")
    if S.client:
        S.client.close()
        S.client = None
    c = paramiko.SSHClient()
    c.load_system_host_keys()
    if not os.path.exists(KNOWN_HOSTS):
        open(KNOWN_HOSTS, "a").close()
    c.load_host_keys(KNOWN_HOSTS)
    c.set_missing_host_key_policy(AskPolicy(d.get("accept_fp")))
    try:
        c.connect(host, port=port, username=user, password=password, key_filename=keyfile,
                  timeout=15, banner_timeout=20, auth_timeout=20,
                  look_for_keys=not (password or keyfile), allow_agent=not (password or keyfile))
    except UnknownHost as e:
        return err("Kunci host belum dikenal.", need_confirm=True,
                   fingerprint=fingerprint(e.key), keytype=e.key.get_name())
    except paramiko.BadHostKeyException:
        return err("PERINGATAN: kunci host server berbeda dari yang tersimpan. Bisa jadi server diganti atau ada penyadapan. "
                   f"Bila Anda yakin server memang berubah, hapus baris host ini di {KNOWN_HOSTS}.")
    except paramiko.AuthenticationException:
        return err("Login ditolak. Periksa pengguna dan sandi/kunci SSH. Di cPanel, pastikan akses SSH diaktifkan.")
    except (socket.timeout, TimeoutError):
        return err(f"Waktu habis saat menghubungi {host}:{port}. Periksa host, port, dan firewall.")
    except Exception as e:  # noqa: BLE001
        return err(f"Gagal tersambung: {e}")
    c.get_transport().set_keepalive(30)
    S.client, S.host, S.user = c, host, user
    S.backups.clear()
    S.swaps.clear()
    _, out, _ = c.exec_command("echo $HOME", timeout=15)
    S.home = out.read().decode().strip() or f"/home/{user}"
    return jsonify(ok=True, home=S.home, host=host, user=user)


@app.post("/api/disconnect")
def api_disconnect():
    if S.client:
        S.client.close()
    S.client = None
    return jsonify(ok=True)


@app.post("/api/preview/<step>")
def api_preview(step):
    if step not in PLANS:
        return err("Langkah tidak dikenal.")
    try:
        S.require()
        plan = PLANS[step](request.get_json(force=True) or {}, preview=True)
        return jsonify(ok=True, script=plan.get("display") or plan.get("script", ""))
    except Bad as e:
        return err(str(e))
    except Exception as e:  # noqa: BLE001
        return err(f"Tidak bisa menyusun perintah: {e}")


@app.post("/api/run/<step>")
def api_run(step):
    if step not in PLANS:
        return err("Langkah tidak dikenal.")
    try:
        S.require()
        plan = PLANS[step](request.get_json(force=True) or {})
        job = start_job(plan)
        return jsonify(ok=True, job=job.id)
    except Bad as e:
        return err(str(e))
    except Exception as e:  # noqa: BLE001
        return err(f"Tidak bisa memulai: {e}")


@app.get("/api/job/<jid>")
def api_job(jid):
    job = JOBS.get(jid)
    if not job:
        return err("Proses tidak ditemukan.")
    since = int(request.args.get("since", 0))
    return jsonify(ok=True, events=job.events[since:], next=len(job.events),
                   finished=job.finished, success=job.ok, data=job.data if job.finished else {})


def free_port(start):
    for p in range(start, start + 20):
        with socket.socket() as s:
            try:
                s.bind(("127.0.0.1", p))
                return p
            except OSError:
                continue
    return start


def main():
    port = free_port(int(os.environ.get("DAFFA_PORT", "8765")))
    url = f"http://127.0.0.1:{port}/"
    print("=" * 52)
    print("  Daffa Upgrade OJS berjalan di:", url)
    print("  Biarkan jendela ini terbuka. Tekan Ctrl+C untuk berhenti.")
    print("=" * 52)
    if os.environ.get("DAFFA_NO_BROWSER") != "1":
        threading.Timer(1.0, lambda: webbrowser.open(url)).start()
    app.run(host="127.0.0.1", port=port, threaded=True, debug=False, use_reloader=False)


if __name__ == "__main__":
    main()
