<?php
/**
 * ValkeyMgr — per-customer Valkey lifecycle management.
 * See docs/superpowers/specs/2026-04-20-customer-valkey-design.md
 */

// ValkeyMgr instantiates DockerAPI internally; pull in its definition here so
// callers (api/valkey.php, CLI test scripts, usermgmt.php::delete_user) don't
// have to know about this dependency.
require_once __DIR__ . '/docker-api.php';

class ValkeyMgr {
    const IMAGE = 'valkey/valkey:8-alpine';
    const VERSION = '8.0';
    const NET_DRIVER = 'bridge';
    const HOSTNAME_ALIAS = 'valkey';

    // Memory cap band enforced by generate_config and, transitively, by every
    // lifecycle method that goes through it (enable, resize, recreate).
    const MIN_MEMORY_MB = 32;
    const MAX_MEMORY_MB = 256;

    const VALID_MODES = ['cache', 'snapshot'];
    const VALID_POLICIES = [
        'allkeys-lru', 'allkeys-lfu',
        'volatile-lru', 'volatile-lfu', 'volatile-ttl', 'volatile-random',
        'allkeys-random', 'noeviction'
    ];

    private $db;

    public function __construct($pdo) {
        $this->db = $pdo;
    }

    public function is_valid_mode($mode) {
        return in_array($mode, self::VALID_MODES, true);
    }

    public function is_valid_eviction_policy($policy) {
        return in_array($policy, self::VALID_POLICIES, true);
    }

    public function network_name($username) {
        return 'cust-' . $username . '-valkey-net';
    }

    public function container_name($username) {
        return 'valkey-cust-' . $username;
    }

    public function host_dir($username) {
        if (!preg_match('/^[a-z][a-z0-9_-]{0,31}$/', $username)) {
            throw new InvalidArgumentException("invalid username: $username");
        }
        // WHP customer storage lives under /docker/users/<user>/, NOT /home/<user>/.
        // The original /home/<user>/ path was wrong: it isn't a WHP customer
        // directory on the host (only used as the in-container WP path via the
        // app's bind mount) AND the WHP safety-net open_basedir blocks /home/.
        // Result: prepare_host_dir's mkdir + file_put_contents both failed
        // with open_basedir violations (suppressed @ or unchecked return), the
        // container started anyway, Docker auto-created the bind-mount source
        // as the image's USER (UID 999), and the container immediately died
        // with "can't open /data/valkey.conf". Bit alsacorp 2026-06-01.
        return '/docker/users/' . $username . '/valkey';
    }

    /**
     * Generate valkey.conf body for given settings.
     * @param array $cfg ['memory_mb'=>int, 'mode'=>'cache'|'snapshot', 'eviction_policy'=>string]
     */
    public function generate_config(array $cfg) {
        $mem = intval($cfg['memory_mb']);
        $mode = $cfg['mode'];
        $policy = $cfg['eviction_policy'];
        if (!$this->is_valid_mode($mode)) throw new InvalidArgumentException("bad mode: $mode");
        if (!$this->is_valid_eviction_policy($policy)) throw new InvalidArgumentException("bad policy: $policy");
        if ($mem < self::MIN_MEMORY_MB || $mem > self::MAX_MEMORY_MB) {
            throw new InvalidArgumentException(
                "memory_mb $mem outside allowed band [" . self::MIN_MEMORY_MB . ", " . self::MAX_MEMORY_MB . "]"
            );
        }

        $lines = [
            "# Generated by WHP ValkeyMgr — do not edit by hand",
            "bind 0.0.0.0",
            "protected-mode no",
            "maxmemory {$mem}mb",
            "maxmemory-policy $policy",
            "logfile /data/logs/valkey.log",
        ];
        if ($mode === 'cache') {
            $lines[] = 'save ""';
            $lines[] = 'appendonly no';
        } else {
            $lines[] = 'save 300 1';
            $lines[] = 'appendonly no';
            $lines[] = 'dir /data';
            $lines[] = 'dbfilename dump.rdb';
            $lines[] = 'stop-writes-on-bgsave-error no';
        }
        return implode("\n", $lines) . "\n";
    }

    // ---- lifecycle --------------------------------------------------------

    /**
     * Enable Valkey for a customer. Idempotent: if already enabled, returns existed=true.
     * @param array $cfg ['memory_mb', 'mode', 'eviction_policy']
     * @param array $app_containers  list of container names currently belonging to this customer
     */
    public function enable($username, array $cfg, array $app_containers) {
        $lockPath = "/tmp/whp-valkey-" . $username . ".lock";
        $lockFp = fopen($lockPath, 'c');
        if (!$lockFp || !flock($lockFp, LOCK_EX | LOCK_NB)) {
            if ($lockFp) fclose($lockFp);
            return ['success' => false, 'error' => 'valkey action already in progress for this customer (busy)'];
        }

        $rollback = [];
        try {
            $row = $this->get_row($username);
            if ($row && intval($row['enabled']) === 1) {
                return ['success' => true, 'existed' => true];
            }

            if (!$this->is_valid_mode($cfg['mode'])) throw new RuntimeException('invalid mode');
            if (!$this->is_valid_eviction_policy($cfg['eviction_policy'])) throw new RuntimeException('invalid eviction policy');
            $mem = intval($cfg['memory_mb']);
            if ($mem <= 0) throw new RuntimeException('invalid memory_mb');

            $netName = $this->network_name($username);
            $ctrName = $this->container_name($username);
            $hostDir = $this->host_dir($username);

            $this->upsert_row($username, [
                'enabled' => 0,
                'memory_mb' => $mem,
                'mode' => $cfg['mode'],
                'eviction_policy' => $cfg['eviction_policy'],
                'version' => self::VERSION,
                'network_name' => $netName,
                'container_name' => $ctrName
            ]);
            $rollback[] = function() use ($username) { $this->delete_row($username); };

            $docker = new DockerAPI();
            $net = $docker->create_network($netName);
            if (!$net['success']) throw new RuntimeException('network create failed: ' . ($net['error'] ?? ''));
            $rollback[] = function() use ($docker, $netName) { $docker->remove_network($netName); };

            $this->prepare_host_dir($hostDir, $username);
            $confPath = $hostDir . '/valkey.conf';
            // file_put_contents returns false on failure (open_basedir,
            // permissions, disk full). Without this check the container starts
            // bind-mounting an empty dir, immediately dies looking for the
            // missing /data/valkey.conf, Docker's /containers/X/start API
            // reports success regardless (it doesn't wait for the entrypoint
            // to settle), and enable() walks all the way to enabled=1 with a
            // dead container nobody notices. Bit alsacorp 2026-06-01.
            $written = file_put_contents($confPath, $this->generate_config([
                'memory_mb' => $mem,
                'mode' => $cfg['mode'],
                'eviction_policy' => $cfg['eviction_policy']
            ]));
            if ($written === false) {
                $err = error_get_last();
                throw new RuntimeException(
                    'valkey.conf write to ' . $confPath . ' failed: '
                    . ($err['message'] ?? 'unknown error')
                );
            }
            @chmod($confPath, 0644);
            $rollback[] = function() use ($hostDir) { $this->purge_host_dir($hostDir); };

            // If a dump.rdb already exists at /docker/users/$user/valkey/data/dump.rdb
            // (e.g., staged from the customer's backup manager before re-enable),
            // Valkey will auto-load it on boot via the bind-mounted volume.

            $created = $this->create_valkey_container($docker, [
                'image' => self::IMAGE,
                'name' => $ctrName,
                'volumes' => [$hostDir . ':/data'],
                'memory_mb' => $mem,
                'cmd' => ['valkey-server', '/data/valkey.conf'],
                'labels' => ['whp.valkey.username' => $username],
                'network' => $netName,
                'network_aliases' => [self::HOSTNAME_ALIAS]
            ]);
            if (!$created['success']) throw new RuntimeException('container create failed: ' . ($created['error'] ?? ''));
            $rollback[] = function() use ($ctrName) {
                // Force-remove on rollback; ignore errors since this is best-effort cleanup.
                dockerApiRequest('DELETE', '/containers/' . urlencode($ctrName) . '?force=true');
            };

            $start = dockerApiRequest('POST', '/containers/' . urlencode($ctrName) . '/start');
            if (!$start['success']) throw new RuntimeException('container start failed: ' . ($start['error'] ?? ''));

            // Docker's /start returns 204 immediately after issuing the start
            // command — well before the entrypoint has had a chance to crash.
            // Poll for ~2s and confirm the container is actually Running, so
            // an immediate-crash (bad conf, missing image dep, OOM at start)
            // surfaces as a failed enable() instead of enabled=1 + dead
            // container. Bit alsacorp 2026-06-01 (paired with the host_dir
            // bug above).
            $running = false;
            for ($attempt = 0; $attempt < 8; $attempt++) {
                usleep(250000); // 250ms
                $inspect = dockerApiRequest('GET', '/containers/' . urlencode($ctrName) . '/json');
                $state = $inspect['data']['State'] ?? [];
                if (!empty($state['Running'])) {
                    $running = true;
                    break;
                }
                // Bail early on a definitive failure — don't waste the full 2s.
                if (!empty($state['Status']) && in_array($state['Status'], ['exited', 'dead'], true)) {
                    $exit_code = $state['ExitCode'] ?? '?';
                    $logs = dockerApiRequest(
                        'GET',
                        '/containers/' . urlencode($ctrName) . '/logs?stdout=true&stderr=true&tail=20'
                    );
                    $tail = is_string($logs['data'] ?? null) ? trim($logs['data']) : '';
                    throw new RuntimeException(
                        'container ' . $ctrName . ' crashed on start (exit=' . $exit_code . '): '
                        . ($tail !== '' ? $tail : '(no log output)')
                    );
                }
            }
            if (!$running) {
                throw new RuntimeException(
                    'container ' . $ctrName . ' did not reach Running state within 2s'
                );
            }

            foreach ($app_containers as $app) {
                $r = $docker->connect_network($netName, $app);
                if (!$r['success']) {
                    error_log("valkey: failed to attach app container $app to $netName: " . ($r['error'] ?? ''));
                    // Non-fatal per spec; UI badge surfaces the issue.
                }
            }

            $this->upsert_row($username, ['enabled' => 1]);
            return ['success' => true, 'existed' => false];
        } catch (Throwable $e) {
            while ($fn = array_pop($rollback)) {
                try { $fn(); } catch (Throwable $ignored) { error_log('valkey rollback step failed: ' . $ignored->getMessage()); }
            }
            return ['success' => false, 'error' => $e->getMessage(), 'rolled_back_at' => date('c')];
        } finally {
            flock($lockFp, LOCK_UN);
            fclose($lockFp);
        }
    }

    /**
     * Disable and tear down Valkey for a customer. Idempotent.
     * @param callable|null $backup_hook nullable fn($username) triggering an immediate user-files backup
     */
    public function disable($username, array $app_containers, callable $backup_hook = null) {
        $lockPath = "/tmp/whp-valkey-" . $username . ".lock";
        $lockFp = fopen($lockPath, 'c');
        if (!$lockFp || !flock($lockFp, LOCK_EX | LOCK_NB)) {
            if ($lockFp) fclose($lockFp);
            return ['success' => false, 'error' => 'busy'];
        }

        try {
            $row = $this->get_row($username);
            if (!$row) return ['success' => true];

            $docker = new DockerAPI();
            $netName = $row['network_name'] ?: $this->network_name($username);
            $ctrName = $row['container_name'] ?: $this->container_name($username);
            $hostDir = $this->host_dir($username);

            if ($row['mode'] === 'snapshot') {
                $res = $docker->run_in_container($ctrName, ['valkey-cli', 'BGSAVE']);
                if ($res['success']) {
                    $deadline = time() + 30;
                    while (time() < $deadline) {
                        $info = $docker->run_in_container($ctrName, ['valkey-cli', 'INFO', 'persistence']);
                        if ($info['success'] && strpos($info['output'], 'rdb_bgsave_in_progress:0') !== false) break;
                        sleep(1);
                    }
                }
                if ($backup_hook) {
                    try { $backup_hook($username); } catch (Throwable $e) { error_log('valkey disable backup_hook failed: ' . $e->getMessage()); }
                }
            }

            // Force-remove in one atomic DELETE (stops + removes regardless of state).
            // The sequential stop → remove pattern was returning success even when
            // DELETE silently failed due to a transitional container state.
            $rm = dockerApiRequest('DELETE', '/containers/' . urlencode($ctrName) . '?force=true');
            if (!$rm['success'] && isset($rm['error']) && stripos($rm['error'], 'No such container') === false) {
                throw new RuntimeException('container remove failed: ' . ($rm['error'] ?? ''));
            }

            foreach ($app_containers as $app) {
                $docker->disconnect_network($netName, $app, true);
            }

            $docker->remove_network($netName);
            $this->purge_host_dir($hostDir);
            $this->delete_row($username);

            return ['success' => true];
        } catch (Throwable $e) {
            return ['success' => false, 'error' => $e->getMessage()];
        } finally {
            flock($lockFp, LOCK_UN);
            fclose($lockFp);
        }
    }

    /**
     * Recreate a customer's Valkey container in place — force-removes the current
     * container, pulls the latest image, and brings up a new one with the same
     * DB-stored settings. Preserves the host filesystem (valkey.conf + data/)
     * so Snapshot-mode instances come back with their keys intact on first boot.
     *
     * Intended for post-image-pull security bumps: root pulls a new image, then
     * recreates each instance to pick it up.
     *
     * @param string $username
     * @param array  $app_containers  list of customer app containers to re-attach to the network
     */
    public function recreate($username, array $app_containers) {
        $lockPath = "/tmp/whp-valkey-" . $username . ".lock";
        $lockFp = fopen($lockPath, 'c');
        if (!$lockFp || !flock($lockFp, LOCK_EX | LOCK_NB)) {
            if ($lockFp) fclose($lockFp);
            return ['success' => false, 'error' => 'busy'];
        }
        try {
            $row = $this->get_row($username);
            if (!$row || intval($row['enabled']) !== 1) {
                return ['success' => false, 'error' => 'not enabled'];
            }

            $docker = new DockerAPI();
            $netName = $row['network_name'] ?: $this->network_name($username);
            $ctrName = $row['container_name'] ?: $this->container_name($username);
            $hostDir = $this->host_dir($username);

            // Force-remove the existing container; filesystem stays.
            dockerApiRequest('DELETE', '/containers/' . urlencode($ctrName) . '?force=true');

            // Ensure host_dir + valkey.conf exist before starting. The normal
            // recreate case (post-image-pull bump) finds them already there
            // from the prior enable(). The host-dir migration case (e.g.
            // 2026.06.20 path change /home/<u>/valkey → /docker/users/<u>/valkey)
            // arrives with a brand-new empty hostDir, so we need to materialize
            // the conf from current DB settings or the container will crash
            // with "can't open /data/valkey.conf" identically to the bug we
            // just fixed in enable(). Regenerating from DB is safe: settings
            // are the source of truth.
            $this->prepare_host_dir($hostDir, $username);
            $confPath = $hostDir . '/valkey.conf';
            if (!is_file($confPath)) {
                $written = file_put_contents($confPath, $this->generate_config([
                    'memory_mb'       => intval($row['memory_mb']),
                    'mode'            => $row['mode'],
                    'eviction_policy' => $row['eviction_policy'],
                ]));
                if ($written === false) {
                    $err = error_get_last();
                    return [
                        'success' => false,
                        'error' => 'valkey.conf write to ' . $confPath . ' failed: '
                                 . ($err['message'] ?? 'unknown error')
                    ];
                }
                @chmod($confPath, 0644);
            }

            // Recreate from stored settings. The image reference is fixed at the
            // class constant, so if root pulled a new image for that tag, the
            // create will use the newly-pulled version.
            $created = $this->create_valkey_container($docker, [
                'image'            => self::IMAGE,
                'name'             => $ctrName,
                'volumes'          => [$hostDir . ':/data'],
                'memory_mb'        => intval($row['memory_mb']),
                'cmd'              => ['valkey-server', '/data/valkey.conf'],
                'labels'           => ['whp.valkey.username' => $username],
                'network'          => $netName,
                'network_aliases'  => [self::HOSTNAME_ALIAS]
            ]);
            if (!$created['success']) {
                return ['success' => false, 'error' => 'container create failed: ' . ($created['error'] ?? '')];
            }

            $start = dockerApiRequest('POST', '/containers/' . urlencode($ctrName) . '/start');
            if (!$start['success']) {
                return ['success' => false, 'error' => 'container start failed: ' . ($start['error'] ?? '')];
            }

            // Same post-start health check as enable() — Docker /start returns
            // success before the entrypoint has had a chance to crash.
            $running = false;
            for ($attempt = 0; $attempt < 8; $attempt++) {
                usleep(250000);
                $inspect = dockerApiRequest('GET', '/containers/' . urlencode($ctrName) . '/json');
                $state = $inspect['data']['State'] ?? [];
                if (!empty($state['Running'])) { $running = true; break; }
                if (!empty($state['Status']) && in_array($state['Status'], ['exited', 'dead'], true)) {
                    $exit_code = $state['ExitCode'] ?? '?';
                    $logs = dockerApiRequest(
                        'GET',
                        '/containers/' . urlencode($ctrName) . '/logs?stdout=true&stderr=true&tail=20'
                    );
                    $tail = is_string($logs['data'] ?? null) ? trim($logs['data']) : '';
                    return [
                        'success' => false,
                        'error' => 'container ' . $ctrName . ' crashed on start (exit=' . $exit_code . '): '
                                 . ($tail !== '' ? $tail : '(no log output)')
                    ];
                }
            }
            if (!$running) {
                return [
                    'success' => false,
                    'error' => 'container ' . $ctrName . ' did not reach Running state within 2s'
                ];
            }

            // Reconnect customer app containers (idempotent — does nothing if still attached).
            foreach ($app_containers as $app) {
                $docker->connect_network($netName, $app);
            }

            // Stamp the version column with whatever IMAGE is currently pinned
            // in code, so the UI reflects a post-upgrade state.
            $this->upsert_row($username, ['version' => self::VERSION]);
            return ['success' => true];
        } catch (Throwable $e) {
            return ['success' => false, 'error' => $e->getMessage()];
        } finally {
            flock($lockFp, LOCK_UN);
            fclose($lockFp);
        }
    }

    /**
     * Enumerate every enabled Valkey instance with its live runtime info.
     * Used by the root fleet-overview page.
     * Returns an array of ['username', 'mode', 'memory_mb', 'eviction_policy',
     * 'version', 'container_name', 'status', 'uptime_sec', 'key_count',
     * 'memory_used_bytes', 'last_save_unix'].
     */
    public function list_fleet() {
        $stmt = $this->db->query("
            SELECT username, mode, memory_mb, eviction_policy, version,
                   network_name, container_name, created_at
            FROM whp.customer_valkey
            WHERE enabled = 1
            ORDER BY username ASC
        ");
        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];

        $out = [];
        foreach ($rows as $r) {
            $live = $this->get_info($r['username']);
            $entry = [
                'username'        => $r['username'],
                'mode'            => $r['mode'],
                'memory_mb'       => intval($r['memory_mb']),
                'eviction_policy' => $r['eviction_policy'],
                'version'         => $r['version'],
                'container_name'  => $r['container_name'],
                'created_at'      => $r['created_at'],
                'status'          => $live['status'] ?? 'unknown',
                'uptime_sec'      => $live['uptime_sec'] ?? 0,
                'key_count'       => $live['key_count'] ?? 0,
                'memory_used_bytes' => $live['memory_used_bytes'] ?? 0,
                'last_save_unix'  => $live['last_save_unix'] ?? 0
            ];
            $out[] = $entry;
        }
        return $out;
    }

    /** Pull the pinned Valkey image. Returns the DockerAPI result array. */
    public function pull_image() {
        $docker = new DockerAPI();
        return $docker->pull_image_manual(self::IMAGE);
    }

    /** Restart a customer's Valkey container by username (admin action). */
    public function restart($username) {
        $row = $this->get_row($username);
        if (!$row) return ['success' => false, 'error' => 'not enabled'];
        $docker = new DockerAPI();
        return $docker->restart_container($row['container_name']);
    }

    // ---- helpers ----------------------------------------------------------

    private function prepare_host_dir($dir, $username) {
        foreach ([$dir, $dir . '/data', $dir . '/logs'] as $p) {
            if (!is_dir($p)) mkdir($p, 0750, true);
            @chown($p, $username);
            @chgrp($p, $username);
        }
    }

    /**
     * Recursive directory removal for /docker/users/<user>/valkey.
     *
     * SECURITY: The literal-path regex below only validates the string the
     * caller passed in -- it cannot detect that $canonical itself is a
     * symlink (e.g. /docker/users/joe/valkey -> /etc), and the previous
     * RecursiveDirectoryIterator implementation followed symlinks during
     * recursion AND used unlink($entry->getPathname()) in the else-branch,
     * which dereferences symlinks via the parent path. Either failure mode
     * could escape the /docker/users/<user>/valkey/ subtree.
     *
     * Two layers of defense, either of which alone closes the vector:
     *
     *   1. Containment check: realpath() must ALSO match the
     *      /docker/users/<user>/valkey shape. Defeats $canonical itself being
     *      a symlink whose target is outside the intended tree.
     *
     *   2. Shell rm -rf via escapeshellarg. Coreutils rm does NOT traverse
     *      symlinks during recursion -- it unlinks the link itself and
     *      leaves the target untouched. Matches the pattern in
     *      CpanelBackupImporter::removeDirectory() (commit f7e4cbc).
     *
     * Refs: /tmp/recursive-walker-audit.md HIGH-3.
     */
    private function purge_host_dir($path) {
        $canonical = rtrim($path, '/');
        if (!preg_match('#^/docker/users/[a-z][a-z0-9_-]{0,31}/valkey$#', $canonical)) {
            throw new RuntimeException("purge refused suspicious path: $path");
        }
        // Containment: realpath must also stay under /docker/users/<user>/valkey/.
        // Defeats a symlink-as-root attack where /docker/users/<user>/valkey -> /etc.
        $real = realpath($canonical);
        if ($real !== false && !preg_match('#^/docker/users/[a-z][a-z0-9_-]{0,31}/valkey$#', $real)) {
            throw new RuntimeException("purge refused: realpath '$real' escapes /docker/users/<user>/valkey");
        }
        if (!is_dir($canonical) && !is_link($canonical)) return;

        // Shell rm -rf does NOT follow symlinks (it unlinks them in place).
        // Belt-and-suspenders with the realpath containment check above.
        $cmd = 'rm -rf ' . escapeshellarg($canonical);
        $out = [];
        $rc = 0;
        exec($cmd . ' 2>&1', $out, $rc);
        if ($rc !== 0) {
            error_log("valkey purge_host_dir: rm -rf failed for '$canonical' (rc=$rc): " . implode("\n", $out));
        }
    }

    private function create_valkey_container(DockerAPI $docker, array $cfg) {
        // Attach to the per-customer private network with the 'valkey' DNS alias
        // at create time via NetworkingConfig. Setting NetworkMode: 'none' and
        // then connecting the network afterwards fails with "cannot be connected
        // to multiple networks with one of the networks in private (none) mode".
        // Merge caller-supplied labels with the boot-orchestrator labels that
        // classify this container as a customer-tier service. RestartPolicy is
        // intentionally 'no': the staggered-boot orchestrator starts customer
        // containers in the correct order after all infrastructure is ready.
        $labels = array_merge(
            ['whp.tier' => 'customer', 'whp.autostart' => 'true'],
            $cfg['labels']
        );
        $dockerCfg = [
            'Image' => $cfg['image'],
            'Hostname' => $cfg['name'],
            'Env' => [],
            'Labels' => $labels,
            'Cmd' => $cfg['cmd'],
            'HostConfig' => [
                'RestartPolicy' => ['Name' => 'no'],
                'Binds' => $cfg['volumes'],
                'NetworkMode' => $cfg['network'],
                'Memory' => $cfg['memory_mb'] * 1024 * 1024,
                'MemorySwap' => $cfg['memory_mb'] * 1024 * 1024
            ],
            'NetworkingConfig' => [
                'EndpointsConfig' => [
                    $cfg['network'] => [
                        'Aliases' => $cfg['network_aliases'] ?? []
                    ]
                ]
            ]
        ];
        $res = dockerApiRequest('POST', '/containers/create?name=' . urlencode($cfg['name']), $dockerCfg);
        if (!$res['success'] && isset($res['error']) && stripos($res['error'], 'No such image') !== false) {
            $docker->pull_image_manual($cfg['image']);
            $res = dockerApiRequest('POST', '/containers/create?name=' . urlencode($cfg['name']), $dockerCfg);
        }
        return $res;
    }

    // ---- DB access --------------------------------------------------------

    public function get_row($username) {
        $stmt = $this->db->prepare("SELECT * FROM whp.customer_valkey WHERE username = ?");
        $stmt->execute([$username]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ?: null;
    }

    private function upsert_row($username, array $fields) {
        $existing = $this->get_row($username);
        if ($existing === null) {
            $cols = array_merge(['username'], array_keys($fields));
            $placeholders = implode(',', array_fill(0, count($cols), '?'));
            $sql = 'INSERT INTO whp.customer_valkey (' . implode(',', $cols) . ') VALUES (' . $placeholders . ')';
            $vals = array_merge([$username], array_values($fields));
            $this->db->prepare($sql)->execute($vals);
        } else {
            $sets = implode(', ', array_map(fn($k) => "$k = ?", array_keys($fields)));
            $sql = "UPDATE whp.customer_valkey SET $sets WHERE username = ?";
            $vals = array_merge(array_values($fields), [$username]);
            $this->db->prepare($sql)->execute($vals);
        }
    }

    private function delete_row($username) {
        $this->db->prepare("DELETE FROM whp.customer_valkey WHERE username = ?")
            ->execute([$username]);
    }

    /**
     * Change the memory cap. Live-updates the Docker memory limit, then restarts so Valkey
     * re-reads the regenerated valkey.conf.
     */
    public function resize($username, $new_memory_mb, array $app_containers) {
        $lockPath = "/tmp/whp-valkey-" . $username . ".lock";
        $lockFp = fopen($lockPath, 'c');
        if (!$lockFp || !flock($lockFp, LOCK_EX | LOCK_NB)) {
            if ($lockFp) fclose($lockFp);
            return ['success' => false, 'error' => 'busy'];
        }
        try {
            $row = $this->get_row($username);
            if (!$row || intval($row['enabled']) !== 1) return ['success' => false, 'error' => 'not enabled'];

            $new_memory_mb = intval($new_memory_mb);
            if ($new_memory_mb <= 0) return ['success' => false, 'error' => 'invalid memory_mb'];

            $docker = new DockerAPI();
            $ctrName = $row['container_name'];
            $hostDir = $this->host_dir($username);

            file_put_contents($hostDir . '/valkey.conf', $this->generate_config([
                'memory_mb' => $new_memory_mb,
                'mode' => $row['mode'],
                'eviction_policy' => $row['eviction_policy']
            ]));

            $update = dockerApiRequest('POST', '/containers/' . urlencode($ctrName) . '/update', [
                'Memory' => $new_memory_mb * 1024 * 1024,
                'MemorySwap' => $new_memory_mb * 1024 * 1024
            ]);
            if (!$update['success']) return ['success' => false, 'error' => 'memory update failed: ' . ($update['error'] ?? '')];

            $docker->restart_container($ctrName);
            $this->upsert_row($username, ['memory_mb' => $new_memory_mb]);
            return ['success' => true];
        } finally {
            flock($lockFp, LOCK_UN);
            fclose($lockFp);
        }
    }

    /** Return live Valkey stats for the UI; never throws. */
    public function get_info($username) {
        $row = $this->get_row($username);
        if (!$row) return ['status' => 'not_enabled'];

        $docker = new DockerAPI();
        $ctrName = $row['container_name'];

        $inspect = $docker->inspect_container($ctrName);
        if (!$inspect['success']) return ['status' => 'stopped', 'row' => $row];
        $running = ($inspect['data']['State']['Running'] ?? false) === true;
        if (!$running) return ['status' => 'stopped', 'row' => $row];

        $info = $docker->run_in_container($ctrName, ['valkey-cli', 'INFO']);
        $parsed = $this->parse_info_output($info['success'] ? $info['output'] : '');
        return [
            'status' => 'running',
            'row' => $row,
            'memory_used_bytes' => intval($parsed['used_memory'] ?? 0),
            'key_count' => intval($parsed['db0_keys'] ?? 0),
            'uptime_sec' => intval($parsed['uptime_in_seconds'] ?? 0),
            'last_save_unix' => intval($parsed['rdb_last_save_time'] ?? 0)
        ];
    }

    private function parse_info_output($raw) {
        $out = [];
        foreach (preg_split('/\r?\n/', $raw) as $line) {
            if ($line === '' || $line[0] === '#') continue;
            $pos = strpos($line, ':');
            if ($pos === false) continue;
            $k = trim(substr($line, 0, $pos));
            $v = trim(substr($line, $pos + 1));
            if ($k === 'db0' && preg_match('/keys=(\d+)/', $v, $m)) {
                $out['db0_keys'] = $m[1];
            } else {
                $out[$k] = $v;
            }
        }
        return $out;
    }

}
