<?php
/**
 * Tam Rank™ .htaccess Editor (FREE)
 *
 * A Yoast-style file editor for the site's .htaccess, living next to the
 * robots.txt + sitemap tools in Settings → Technical SEO.
 *
 * Safety model — "restore unless proven healthy":
 *
 * A broken .htaccess makes Apache/LiteSpeed answer 500 BEFORE PHP runs, so
 * once the file is bad there is no WordPress left to heal the site: no cron,
 * no shutdown hook, no deferred recovery of any kind. The only code that is
 * guaranteed to keep running is the PHP request that performed the write (it
 * passed .htaccess parsing before the file changed). Every keep-or-restore
 * decision is therefore made synchronously INSIDE that request, and the
 * default is inverted: the new file is kept only when the site provably
 * still answers; every unclear outcome restores the previous version.
 *
 *   1. Baseline probe (before anything is written): a loopback to the
 *      homepage proves the safety net exists on this host. When it does not
 *      (blocked loopback) or the site is already erroring, the user must
 *      explicitly confirm — the UI never promises protection the host
 *      cannot deliver.
 *   2. Sandbox pre-flight: the candidate rules are written next to a static
 *      canary file in a throw-away uploads folder and the canary is fetched.
 *      The web server parses the candidate THERE, so a typo 500s only that
 *      URL — the real .htaccess is never the experiment. The sandbox first
 *      calibrates itself with a known-invalid file; when the host does not
 *      honour .htaccess under uploads (AllowOverride off, offloaded media,
 *      LiteSpeed leniency) the pre-flight reports "inconclusive" and the
 *      commit stage below carries the safety alone.
 *   3. Fail-safe commit: rolling backup (last 5, DB option, autoload off) →
 *      write → probe again → restore unless the probe is unambiguously OK.
 *      A 5xx, a timeout, a refused connection and a blocked loopback all
 *      restore. ignore_user_abort() guarantees the decision completes even
 *      when the admin closes the tab mid-save.
 *   4. Browser second observer: after a kept save the admin's own browser
 *      fetches the homepage (a viewpoint a server-side firewall cannot
 *      block) and reports to a single-use-token endpoint that may trigger a
 *      restore. It can only ADD safety: the server-side decision is already
 *      final within the save request, so a closed tab simply leaves that
 *      safe outcome in place.
 *
 * Additional hardening (wp.org file-editing guidelines): only active on
 * Apache/LiteSpeed (read-only notice elsewhere), NONCE + manage_options on
 * every endpoint (super-admin only on multisite), honours
 * DISALLOW_FILE_MODS/DISALLOW_FILE_EDIT, tag-balance syntax check before any
 * write, all file I/O through WP_Filesystem.
 *
 * The editor never writes rules by itself — it only reads and saves what the
 * administrator typed.
 */

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

class TamRank_Htaccess_Editor
{
    /** Option holding the rolling backups (autoload off). */
    const BACKUP_OPTION = 'tam_rank_htaccess_backups';

    /** How many backups to keep. */
    const MAX_BACKUPS = 5;

    /** Hard ceiling on .htaccess size we will accept (512 KB). */
    const MAX_BYTES = 524288;

    /** Nonce action shared by all AJAX endpoints. */
    const NONCE_ACTION = 'tamrank_htaccess';

    /**
     * Loopback timeout in seconds, identical for the baseline and the
     * after-write probe so the two are directly comparable. Kept at 8s on
     * purpose: a shorter timeout would false-restore on slow shared hosts
     * whose baseline legitimately takes 5-7s, while a genuine hang is still
     * caught — the probe errors out at 8s and the fail-safe restores.
     */
    const LOOPBACK_TIMEOUT = 8;

    /** Timeout for the sandbox canary fetches (static file, should be fast). */
    const CANARY_TIMEOUT = 5;

    /** Prefix of the throw-away pre-flight folders under uploads. */
    const PREFLIGHT_PREFIX = 'tamrank-preflight-';

    /** Transient arming the browser second observer (single-use token). */
    const VERIFY_TRANSIENT = 'tam_rank_htaccess_verify';

    /** How long the browser observer may report back (seconds). */
    const VERIFY_TTL = 120;

    /** Sandbox folder of the in-flight pre-flight, for the shutdown sweep. */
    private static $preflight_dir = '';

    public function __construct()
    {
        add_action('wp_ajax_tamrank_htaccess_save', array($this, 'ajax_save'));
        add_action('wp_ajax_tamrank_htaccess_restore', array($this, 'ajax_restore'));
        add_action('wp_ajax_tamrank_htaccess_client_verify', array($this, 'ajax_client_verify'));
    }

    /* ===================================================================
     * Server detection
     * =================================================================== */

    /**
     * Coarse server family from SERVER_SOFTWARE.
     *
     * @return string apache|litespeed|nginx|iis|other|unknown
     */
    public static function detect_server()
    {
        $sw = isset($_SERVER['SERVER_SOFTWARE'])
            ? strtolower(sanitize_text_field(wp_unslash($_SERVER['SERVER_SOFTWARE'])))
            : '';

        if ($sw === '') {
            return 'unknown';
        }
        if (strpos($sw, 'litespeed') !== false) {
            return 'litespeed';
        }
        if (strpos($sw, 'apache') !== false) {
            return 'apache';
        }
        if (strpos($sw, 'nginx') !== false) {
            return 'nginx';
        }
        if (strpos($sw, 'microsoft-iis') !== false || strpos($sw, 'iis') !== false) {
            return 'iis';
        }
        return 'other';
    }

    /**
     * Human-facing server label (proper nouns, not translated).
     *
     * @return string
     */
    public static function server_label()
    {
        switch (self::detect_server()) {
            case 'apache':
                return 'Apache';
            case 'litespeed':
                return 'LiteSpeed';
            case 'nginx':
                return 'nginx';
            case 'iis':
                return 'Microsoft IIS';
            default:
                return tamrank__('htaccess_server_unknown', 'your web server');
        }
    }

    /**
     * Whether the .htaccess editor should be active. Apache + LiteSpeed read
     * .htaccess; everything else ignores it. Filterable as an escape hatch for
     * setups that mis-report SERVER_SOFTWARE behind a proxy.
     *
     * @return bool
     */
    public static function is_supported_server()
    {
        $supported = in_array(self::detect_server(), array('apache', 'litespeed'), true);

        /**
         * Filter whether the .htaccess editor is active.
         *
         * @param bool   $supported Detected support.
         * @param string $server    Detected server family.
         */
        return (bool) apply_filters('tamrank_htaccess_supported', $supported, self::detect_server());
    }

    /**
     * True when the site owner disabled dashboard file-editing via wp-config
     * (DISALLOW_FILE_MODS implies DISALLOW_FILE_EDIT). Core's own theme/plugin
     * editors honour these constants; a raw .htaccess editor must never be
     * more permissive than core itself.
     *
     * @return bool
     */
    public static function is_editing_disabled()
    {
        return (defined('DISALLOW_FILE_MODS') && DISALLOW_FILE_MODS)
            || (defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT);
    }

    /**
     * Capability gate. On multisite the .htaccess is ONE network-wide file,
     * so a per-subsite manage_options is not enough: a subsite admin could
     * otherwise rewrite server rules for every site in the network. Mirror
     * core, which restricts file editing on multisite to super admins.
     *
     * @return bool
     */
    public static function current_user_can_edit()
    {
        if (is_multisite() && !is_super_admin()) {
            return false;
        }
        return current_user_can('manage_options');
    }

    /* ===================================================================
     * Filesystem
     * =================================================================== */

    /**
     * Absolute path to the site's .htaccess.
     *
     * @return string
     */
    public static function htaccess_path()
    {
        if (!function_exists('get_home_path')) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }
        return get_home_path() . '.htaccess';
    }

    /**
     * Boot WP_Filesystem in-place (direct method on most hosts).
     *
     * @return bool
     */
    private static function ensure_filesystem()
    {
        global $wp_filesystem;
        if (!empty($wp_filesystem)) {
            return true;
        }
        if (!function_exists('WP_Filesystem')) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }
        // No credentials are passed: this succeeds on the 'direct' method and
        // fails gracefully (returns false) on FTP/SSH hosts, where we cannot
        // prompt from an AJAX context.
        WP_Filesystem();
        return !empty($wp_filesystem);
    }

    /**
     * Read the current .htaccess.
     *
     * @return string|null File contents, or null when it does not exist / cannot be read.
     */
    public static function read()
    {
        $path = self::htaccess_path();

        // Primary path: WP_Filesystem.
        if (self::ensure_filesystem()) {
            global $wp_filesystem;
            if ($wp_filesystem->exists($path)) {
                $content = $wp_filesystem->get_contents($path);
                if ($content !== false) {
                    return self::normalize_encoding($content);
                }
            }
        }

        // Fallback for the read-only display: a plain read. On sites where
        // another plugin has already booted $wp_filesystem with a non-direct
        // transport this request, the WP_Filesystem read above returns nothing
        // even though the file is perfectly readable — which showed the editor
        // as empty. Reading a config file the admin can already view needs no
        // credentials, so we degrade to a native read here. Writes stay
        // WP_Filesystem-only (see write()).
        if (is_readable($path)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- read-only editor display; a WP_Filesystem write path is used for saving.
            $content = @file_get_contents($path);
            if ($content !== false) {
                return self::normalize_encoding($content);
            }
        }

        return null;
    }

    /**
     * Ensure file contents survive WordPress escaping.
     *
     * esc_html()/esc_textarea() return '' for any string that is not valid
     * UTF-8, so a single legacy byte (say a Windows-1252 em-dash in a
     * comment) made the editor render empty while the file had content —
     * and a save from that state would wipe the real rules. Convert legacy
     * bytes to UTF-8 so the content stays visible; the first save then
     * persists the cleaned encoding, guarded by the usual backup.
     *
     * @param string $content Raw file contents.
     * @return string Contents as valid UTF-8.
     */
    private static function normalize_encoding($content)
    {
        if ($content === '' || preg_match('//u', $content)) {
            return $content;
        }
        if (function_exists('mb_convert_encoding')) {
            return mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
        }
        return wp_check_invalid_utf8($content, true);
    }

    /**
     * Write the .htaccess.
     *
     * @param string $content
     * @return bool
     */
    private static function write($content)
    {
        if (!self::ensure_filesystem()) {
            return false;
        }
        global $wp_filesystem;
        return (bool) $wp_filesystem->put_contents(self::htaccess_path(), $content, FS_CHMOD_FILE);
    }

    /**
     * Delete the .htaccess (used when rolling back to a "no file existed" state).
     *
     * @return bool
     */
    private static function delete_file()
    {
        if (!self::ensure_filesystem()) {
            return false;
        }
        global $wp_filesystem;
        $path = self::htaccess_path();
        if (!$wp_filesystem->exists($path)) {
            return true;
        }
        return (bool) $wp_filesystem->delete($path);
    }

    /* ===================================================================
     * Backups (rolling, DB option, autoload off)
     * =================================================================== */

    /**
     * @return array Newest-first list of ['time' => int, 'content' => string, 'bytes' => int].
     */
    public static function get_backups()
    {
        $raw = get_option(self::BACKUP_OPTION, array());
        return is_array($raw) ? $raw : array();
    }

    /**
     * Push the pre-write state onto the rolling backup stack.
     *
     * @param string $content
     */
    private static function push_backup($content)
    {
        $content  = (string) $content;
        $backups  = self::get_backups();
        array_unshift($backups, array(
            'time'    => time(),
            'content' => $content,
            'bytes'   => strlen($content),
        ));
        $backups = array_slice($backups, 0, self::MAX_BACKUPS);
        update_option(self::BACKUP_OPTION, $backups, false);
    }

    /* ===================================================================
     * Syntax sanity check
     * =================================================================== */

    /**
     * A best-effort sanity check — NOT a full Apache parse (impossible without
     * Apache; that is what the sandbox pre-flight is for). It catches the
     * breakage a string scan CAN see: an unbalanced <Section> … </Section>
     * container. Also rejects null bytes and oversized input.
     *
     * @param string $content
     * @return true|WP_Error
     */
    public static function validate_syntax($content)
    {
        if (strpos($content, "\0") !== false) {
            return new WP_Error('null_byte', tamrank__('htaccess_null_byte', 'The file contains invalid characters and was not saved.'));
        }
        if (strlen($content) > self::MAX_BYTES) {
            return new WP_Error('too_large', sprintf(
                tamrank__('htaccess_too_large', 'The file is too large (maximum %s).'),
                size_format(self::MAX_BYTES)
            ));
        }

        $stack  = array();
        $errors = array();
        $lines  = preg_split('/\r\n|\r|\n/', $content);

        foreach ($lines as $i => $line) {
            $lineno = $i + 1;
            $trim   = ltrim($line);
            if ($trim === '' || $trim[0] === '#' || $trim[0] !== '<') {
                continue; // only section-container directives start a line with '<'
            }

            if (preg_match('/^<\/\s*([a-zA-Z]+)\s*>/', $trim, $m)) {
                // closing tag
                $tag = strtolower($m[1]);
                if (empty($stack)) {
                    $errors[] = sprintf(
                        tamrank__('htaccess_err_stray_close', 'Closing %1$s (line %2$d) has no matching opening tag'),
                        '&lt;/' . $m[1] . '&gt;',
                        $lineno
                    );
                } else {
                    $open = array_pop($stack);
                    if ($open['tag'] !== $tag) {
                        $errors[] = sprintf(
                            tamrank__('htaccess_err_mismatch', 'Closing %1$s (line %2$d) does not match opening &lt;%3$s&gt; (line %4$d)'),
                            '&lt;/' . $m[1] . '&gt;',
                            $lineno,
                            esc_html($open['tag']),
                            $open['line']
                        );
                    }
                }
            } elseif (preg_match('/^<\s*([a-zA-Z]+)(\s[^>]*)?>/', $trim, $m)) {
                // opening tag
                $stack[] = array('tag' => strtolower($m[1]), 'line' => $lineno);
            }
        }

        foreach ($stack as $open) {
            $errors[] = sprintf(
                tamrank__('htaccess_err_unclosed', 'Unclosed &lt;%1$s&gt; (line %2$d)'),
                esc_html($open['tag']),
                $open['line']
            );
        }

        if (!empty($errors)) {
            return new WP_Error('unbalanced', sprintf(
                tamrank__('htaccess_unbalanced', 'Syntax check failed: %s. Nothing was saved.'),
                implode('; ', $errors)
            ));
        }
        return true;
    }

    /**
     * Count top-level opening section tags (for the "N blocks balanced" message).
     *
     * @param string $content
     * @return int
     */
    private static function count_blocks($content)
    {
        $count = 0;
        foreach (preg_split('/\r\n|\r|\n/', $content) as $line) {
            $trim = ltrim($line);
            if ($trim === '' || $trim[0] !== '<') {
                continue;
            }
            if (strpos($trim, '</') === 0) {
                continue;
            }
            if (preg_match('/^<\s*[a-zA-Z]+(\s[^>]*)?>/', $trim)) {
                $count++;
            }
        }
        return $count;
    }

    /* ===================================================================
     * Loopback probe + keep-or-restore decision
     * =================================================================== */

    /**
     * Hit the home URL and classify the outcome.
     *
     * Identical parameters for the baseline and the after-write probe, so
     * "it answered before the write and not after" is a meaningful
     * comparison. Redirects are followed (max 3) so a rewrite loop surfaces
     * as a WP_Error instead of hiding behind a single 301 — under the old
     * behaviour (redirection 0) a loop classified as healthy.
     *
     * @return array { state: 'ok'|'broken'|'unreachable', code: int }
     */
    private static function probe()
    {
        // Cache-buster so a cached 200 cannot mask a fresh 500.
        $url = add_query_arg('tamrank_htaccess_selfcheck', strtolower(wp_generate_password(12, false, false)), home_url('/'));

        $response = wp_remote_get($url, array(
            'timeout'     => self::LOOPBACK_TIMEOUT,
            // sslverify uit is hier bewust en veilig: het doel is home_url()
            // (de eigen site), core's eigen loopbacks (Site Health, cron-spawn)
            // doen hetzelfde, en alleen de HTTP-statuscode wordt gelezen — de
            // body wordt nergens vertrouwd. Zelf-ondertekende lokale certs
            // mogen de rollback-check niet breken.
            'sslverify'   => false,
            'redirection' => 3,
            'headers'     => array(
                'Cache-Control' => 'no-cache',
                'Pragma'        => 'no-cache',
            ),
        ));

        if (is_wp_error($response)) {
            return array('state' => 'unreachable', 'code' => 0);
        }
        $code = (int) wp_remote_retrieve_response_code($response);
        if ($code === 0) {
            return array('state' => 'unreachable', 'code' => 0);
        }
        if ($code >= 500 && $code <= 599) {
            return array('state' => 'broken', 'code' => $code);
        }
        return array('state' => 'ok', 'code' => $code);
    }

    /**
     * The keep-or-restore decision as a pure function of the baseline and
     * after-write probe states. Public so a test harness can exercise the
     * full matrix without a web server.
     *
     * The invariant: with a healthy baseline, ONLY an unambiguous OK keeps
     * the new file. 'unreachable' (timeout, refused connection, blocked
     * loopback, WP_Error, status 0) is never a keep — under the old design
     * it degraded to a warning, which left broken files in place on exactly
     * the hosts where a hang was the symptom.
     *
     * @param string $baseline_state ok|broken|unreachable (before the write)
     * @param string $after_state    ok|broken|unreachable (after the write)
     * @return string 'keep'|'restore'
     */
    public static function commit_decision($baseline_state, $after_state)
    {
        if ($after_state === 'ok') {
            return 'keep';
        }
        if ($baseline_state === 'ok') {
            // Healthy before, not provably healthy after: restore. This is
            // the inverted default — broken AND unreachable both land here.
            return 'restore';
        }
        if ($baseline_state === 'broken') {
            // The site already 5xx'd before the write (the user confirmed
            // that explicitly). A 5xx afterwards is the same failure state:
            // restoring would bring back an equally broken file and destroy
            // a repair attempt, so keep. Degrading further (no response at
            // all) restores to the known lesser-broken state.
            return ($after_state === 'broken') ? 'keep' : 'restore';
        }
        // Baseline unreachable: the user confirmed saving without a
        // server-side safety net. A 5xx afterwards is still positive
        // evidence of breakage → restore. Still-unreachable is exactly the
        // blindness that was consented to → keep; the browser observer
        // (ajax_client_verify) can still veto with real-world evidence.
        return ($after_state === 'broken') ? 'restore' : 'keep';
    }

    /* ===================================================================
     * Sandbox pre-flight
     * =================================================================== */

    /**
     * Parse-test the candidate rules in a throw-away folder so the real
     * .htaccess is never the experiment.
     *
     * Self-calibrating: a deliberately invalid file is tested first. Only
     * when THAT yields a 5xx do we know the server actually parses .htaccess
     * at this location — otherwise (AllowOverride off for uploads, offloaded
     * media, LiteSpeed ignoring unknown directives) the verdict is
     * 'inconclusive' and the caller falls through to the fail-safe commit.
     *
     * @param string $content Candidate .htaccess body.
     * @return array { verdict: 'pass'|'fail'|'inconclusive', code: int }
     */
    private static function preflight($content)
    {
        if (!self::ensure_filesystem()) {
            return array('verdict' => 'inconclusive', 'code' => 0);
        }
        global $wp_filesystem;

        $uploads = wp_upload_dir();
        if (!empty($uploads['error']) || empty($uploads['basedir']) || empty($uploads['baseurl'])) {
            return array('verdict' => 'inconclusive', 'code' => 0);
        }

        self::sweep_stale_preflight_dirs($uploads['basedir']);

        $name       = self::PREFLIGHT_PREFIX . strtolower(wp_generate_password(16, false, false));
        $dir        = trailingslashit($uploads['basedir']) . $name;
        $canary_url = trailingslashit($uploads['baseurl']) . $name . '/canary.txt';

        if (!$wp_filesystem->mkdir($dir, FS_CHMOD_DIR)) {
            return array('verdict' => 'inconclusive', 'code' => 0);
        }
        self::$preflight_dir = $dir;
        // Sweep the folder even when a fatal aborts this request mid-flight;
        // the stale sweep above is the second net for hard kills.
        register_shutdown_function(array(__CLASS__, 'preflight_shutdown_cleanup'));

        $written = $wp_filesystem->put_contents($dir . '/canary.txt', "tamrank-canary-ok\n", FS_CHMOD_FILE)
            && $wp_filesystem->put_contents($dir . '/index.html', '', FS_CHMOD_FILE);
        if (!$written) {
            self::cleanup_preflight_dir();
            return array('verdict' => 'inconclusive', 'code' => 0);
        }

        // Calibration: prove the server parses .htaccess here at all. An
        // unknown directive 500s any location where overrides are honoured.
        $invalid = "# TamRank pre-flight calibration probe - intentionally invalid\n"
            . "TamRankPreflightCalibration deliberately_invalid\n";
        if (!$wp_filesystem->put_contents($dir . '/.htaccess', $invalid, FS_CHMOD_FILE)) {
            self::cleanup_preflight_dir();
            return array('verdict' => 'inconclusive', 'code' => 0);
        }
        $calibration = self::fetch_canary($canary_url);
        if ($calibration['code'] < 500) {
            // 200 = overrides ignored here, 403/404 = folder shielded,
            // 0 = unreachable. The sandbox cannot judge the candidate on
            // this host; not a blocker — the commit stage carries the safety.
            self::cleanup_preflight_dir();
            return array('verdict' => 'inconclusive', 'code' => $calibration['code']);
        }

        // The candidate itself, verbatim — prepending or appending anything
        // would change what is being parse-tested.
        if (!$wp_filesystem->put_contents($dir . '/.htaccess', $content, FS_CHMOD_FILE)) {
            self::cleanup_preflight_dir();
            return array('verdict' => 'inconclusive', 'code' => 0);
        }
        $result = self::fetch_canary($canary_url);
        self::cleanup_preflight_dir();

        if ($result['code'] >= 500) {
            return array('verdict' => 'fail', 'code' => $result['code']);
        }
        if ($result['code'] === 0) {
            // Calibration answered but the candidate fetch did not (flaky
            // network): do not block the save on that — commit stage protects.
            return array('verdict' => 'inconclusive', 'code' => 0);
        }
        // Any parsed response counts as sound syntax: 2xx is the canary, and
        // 3xx/4xx mean access-control directives in the candidate are doing
        // their job — an intent, not a parse failure.
        return array('verdict' => 'pass', 'code' => $result['code']);
    }

    /**
     * Fetch the sandbox canary with a cache-buster.
     *
     * @param string $url Canary URL.
     * @return array { code: int } 0 = no usable response.
     */
    private static function fetch_canary($url)
    {
        $bust     = add_query_arg('c', strtolower(wp_generate_password(8, false, false)), $url);
        $response = wp_remote_get($bust, array(
            'timeout'     => self::CANARY_TIMEOUT,
            'sslverify'   => false, // same-site target, status code only — see probe()
            'redirection' => 2,
            'headers'     => array(
                'Cache-Control' => 'no-cache',
                'Pragma'        => 'no-cache',
            ),
        ));
        if (is_wp_error($response)) {
            return array('code' => 0);
        }
        return array('code' => (int) wp_remote_retrieve_response_code($response));
    }

    /**
     * Remove the in-flight sandbox folder (files + folder).
     */
    private static function cleanup_preflight_dir()
    {
        if (self::$preflight_dir === '') {
            return;
        }
        global $wp_filesystem;
        if (!empty($wp_filesystem)) {
            $wp_filesystem->delete(self::$preflight_dir, true);
        }
        self::$preflight_dir = '';
    }

    /**
     * Shutdown hook: last-chance sweep when a fatal aborted the pre-flight.
     */
    public static function preflight_shutdown_cleanup()
    {
        self::cleanup_preflight_dir();
    }

    /**
     * Remove tamrank-preflight-* folders older than an hour. Covers the one
     * case cleanup_preflight_dir() cannot: a hard process kill mid-pre-flight.
     *
     * @param string $basedir Uploads base directory.
     */
    private static function sweep_stale_preflight_dirs($basedir)
    {
        global $wp_filesystem;
        $entries = $wp_filesystem->dirlist($basedir, false, false);
        if (!is_array($entries)) {
            return;
        }
        foreach ($entries as $name => $entry) {
            if (strpos($name, self::PREFLIGHT_PREFIX) !== 0) {
                continue;
            }
            $mtime = isset($entry['lastmodunix']) ? (int) $entry['lastmodunix'] : 0;
            if ($mtime === 0 || (time() - $mtime) > HOUR_IN_SECONDS) {
                $wp_filesystem->delete(trailingslashit($basedir) . $name, true);
            }
        }
    }

    /* ===================================================================
     * AJAX: save
     * =================================================================== */

    public function ajax_save()
    {
        // The keep-or-restore decision below MUST complete even when the
        // admin closes the tab mid-save: a broken .htaccess kills every
        // FUTURE request before PHP runs, so this request is the only place
        // where recovery is guaranteed to be possible.
        ignore_user_abort(true);

        // Order: nonce → capability → config-lock → server support.
        check_ajax_referer(self::NONCE_ACTION, 'nonce');

        if (!self::current_user_can_edit()) {
            wp_send_json_error(array('message' => tamrank__('htaccess_unauthorized', 'You are not allowed to edit this file.')), 403);
        }
        if (self::is_editing_disabled()) {
            wp_send_json_error(array('message' => tamrank__('htaccess_disabled_by_config', 'File editing is disabled on this site (DISALLOW_FILE_EDIT or DISALLOW_FILE_MODS in wp-config.php), so the .htaccess editor is turned off.')), 403);
        }
        if (!self::is_supported_server()) {
            wp_send_json_error(array('message' => tamrank__('htaccess_unsupported_body', 'This server does not use .htaccess files, so the editor is disabled.')), 400);
        }

        // Do NOT run sanitize_text_field on the body — it would strip the
        // newlines and structure that make an .htaccess an .htaccess.
        $raw = isset($_POST['content']) ? wp_unslash($_POST['content']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw .htaccess body; sanitize_text_field would strip the newlines/structure the config needs. Validated below via validate_syntax() before any write.
        if (!is_string($raw)) {
            $raw = '';
        }
        $content = str_replace(array("\r\n", "\r"), "\n", $raw);
        if ($content !== '' && substr($content, -1) !== "\n") {
            $content .= "\n";
        }

        $valid = self::validate_syntax($content);
        if (is_wp_error($valid)) {
            wp_send_json_error(array('message' => $valid->get_error_message()));
        }

        // ── Stage 1: baseline probe, BEFORE anything is written ──────────
        // Proves the safety net exists on this host and makes the after-
        // write probe interpretable (healthy before + not healthy after =
        // this change did it). Without a working baseline the user must
        // explicitly accept saving unprotected — asked BEFORE the save, not
        // apologised for afterwards.
        $baseline  = self::probe();
        $confirmed = !empty($_POST['confirm_risky']);
        if ($baseline['state'] !== 'ok' && !$confirmed) {
            if ($baseline['state'] === 'broken') {
                $message = sprintf(
                    tamrank__('htaccess_baseline_broken_confirm', 'Your site already returns a server error (HTTP %d) before this change. TamRank cannot fully verify the effect of saving: it will only step in if the site stops responding entirely. Continue?'),
                    $baseline['code']
                );
            } else {
                $message = tamrank__('htaccess_no_net_confirm', 'TamRank could not reach your homepage from the server, so the automatic check-and-restore cannot protect this save. If these rules break your site, you will need to repair the file by hand via FTP or your hosting file manager. Keep a copy of the current file before you continue.');
            }
            wp_send_json_error(array(
                'needs_confirmation' => $baseline['state'],
                'message'            => $message,
                'confirm_title'      => tamrank__('htaccess_confirm_title', 'Save without a safety net?'),
                'confirm_label'      => tamrank__('htaccess_save_anyway', 'Save anyway'),
            ));
        }

        // ── Stage 2: sandbox pre-flight — the real file is never the
        // experiment. A "save anyway" override is only honoured while the
        // baseline is healthy, because only then does the fail-safe commit
        // below still protect the site.
        $force     = !empty($_POST['force_preflight']) && $baseline['state'] === 'ok';
        $preflight = array('verdict' => 'forced', 'code' => 0);
        if (!$force) {
            $preflight = self::preflight($content);
            if ($preflight['verdict'] === 'fail') {
                // Cause first, then consequence, then what we did about it. The
                // admin-lockout point matters: .htaccess is parsed before PHP
                // runs, so a broken file locks the owner out of the very screen
                // they would need to fix it.
                $message = tamrank__('htaccess_preflight_failed', "Your server cannot read these rules. Saved, they would have taken your whole site down, including your own WordPress admin.\n\nWe test new rules on a separate copy first, so your real file was not touched and your site is still running.");
                $detail  = sprintf(
                    tamrank__('htaccess_preflight_detail', 'Usually a typo. Sometimes a rule is valid but your host only allows it in the server configuration. (Server error %d)'),
                    $preflight['code']
                );
                if ($baseline['state'] === 'ok') {
                    wp_send_json_error(array(
                        'can_force'       => true,
                        'message'         => $message,
                        'detail'          => $detail,
                        'confirm_title'   => tamrank__('htaccess_preflight_force_title', 'TamRank blocked this'),
                        'confirm_message' => tamrank__('htaccess_preflight_force_confirm', 'Saving anyway stays protected: the after-save check is working on this host, so TamRank restores the previous version if your site stops responding.'),
                        'confirm_label'   => tamrank__('htaccess_save_anyway', 'Save anyway'),
                        'cancel_label'    => tamrank__('htaccess_back_to_editing', 'Back to editing'),
                    ));
                }
                wp_send_json_error(array(
                    'message' => $message . ' ' . tamrank__('htaccess_preflight_no_net', 'Because the automatic self-check cannot run on this host either, this save was blocked entirely.') . ' ' . $detail,
                ));
            }
        }

        // ── Stage 3: fail-safe commit — backup → write → verify →
        // restore unless unambiguously OK, all within this request.
        $previous = self::read();          // string|null
        $had_file = ($previous !== null);

        // Backup the pre-write state BEFORE touching the file.
        self::push_backup($had_file ? $previous : '');

        if (!self::write($content)) {
            wp_send_json_error(array('message' => tamrank__('htaccess_write_failed', 'Could not write .htaccess. Check the file permissions on your server.')));
        }

        $after    = self::probe();
        $decision = self::commit_decision($baseline['state'], $after['state']);

        if ($decision === 'restore') {
            // Restore FIRST, report after — if this request is killed by a
            // host limit mid-recheck, the file is already safe.
            if ($had_file) {
                self::write($previous);
            } else {
                self::delete_file();
            }
            $recheck = self::probe();

            if ($after['state'] === 'broken') {
                $message = tamrank__('htaccess_rolled_back', 'The change caused a server error (5xx) and was rolled back automatically. Your previous .htaccess has been restored; your edit is still in the editor below so you can correct it.');
            } elseif ($baseline['state'] === 'broken') {
                $message = tamrank__('htaccess_got_worse_restored', 'After saving, your site stopped responding at all (before the change it still answered, albeit with an error), so the previous version was restored.');
            } else {
                $message = tamrank__('htaccess_restored_unverified', 'Restored as a precaution: after saving, your site did not respond to the self-check (timeout or connection failure), so the previous version was put back. Your edit is still in the editor below.');
            }
            if ($baseline['state'] === 'ok' && $recheck['state'] !== 'ok') {
                $message .= ' ' . tamrank__('htaccess_rollback_still_broken', 'Warning: the site still returns an error after restoring. The cause may lie elsewhere.');
            }
            wp_send_json_error(array(
                'message'      => $message,
                'rolled_back'  => true,
                'content'      => $had_file ? $previous : '',
                'backups_html' => self::backups_html(),
            ));
        }

        // Kept. Say exactly what was and was not verified.
        $message = sprintf(
            tamrank__('htaccess_saved_ok', 'Syntax OK. %d block(s) balanced. Backup created and .htaccess saved.'),
            self::count_blocks($content)
        );
        $warning = '';
        if ($after['state'] === 'ok') {
            $message .= ' ' . tamrank__('htaccess_verify_ok', 'Self-check passed: your site responds normally.');
            if ($baseline['state'] === 'broken') {
                $message .= ' ' . tamrank__('htaccess_fixed_notice', 'Your site no longer returns a server error after this change.');
            }
            if ($preflight['verdict'] === 'inconclusive') {
                $message .= ' ' . tamrank__('htaccess_preflight_skipped', 'Note: the sandbox pre-test could not run on this host, so the save relied on the after-save check.');
            }
        } elseif ($baseline['state'] === 'broken') {
            $warning = tamrank__('htaccess_still_broken_kept', 'Your site still returns a server error, as it did before this change. The new rules were kept; you can restore a backup below.');
        } else {
            // Baseline and after both unreachable: the consented-to path.
            $warning = tamrank__('htaccess_kept_unverified', 'Unverified: the automatic self-check cannot run on this host, so TamRank cannot confirm your site still works. Check it in a new tab now; if it is broken, restore the file via your hosting file manager or FTP.');
        }

        // Arm the browser second observer: a single-use token the admin's
        // browser reports back with after fetching the homepage itself.
        $token = strtolower(wp_generate_password(24, false, false));
        set_transient(self::VERIFY_TRANSIENT, array(
            'token'    => $token,
            'previous' => $had_file ? $previous : '',
            'had_file' => $had_file,
        ), self::VERIFY_TTL);

        $payload = array(
            'message'      => $message,
            'content'      => $content,
            'backups_html' => self::backups_html(),
            'verify_token' => $token,
        );
        if ($warning !== '') {
            $payload['warning'] = $warning;
        }
        wp_send_json_success($payload);
    }

    /* ===================================================================
     * AJAX: restore a backup
     * =================================================================== */

    public function ajax_restore()
    {
        // See ajax_save(): the decision must survive a closed tab.
        ignore_user_abort(true);

        check_ajax_referer(self::NONCE_ACTION, 'nonce');

        if (!self::current_user_can_edit()) {
            wp_send_json_error(array('message' => tamrank__('htaccess_unauthorized', 'You are not allowed to edit this file.')), 403);
        }
        if (self::is_editing_disabled()) {
            wp_send_json_error(array('message' => tamrank__('htaccess_disabled_by_config', 'File editing is disabled on this site (DISALLOW_FILE_EDIT or DISALLOW_FILE_MODS in wp-config.php), so the .htaccess editor is turned off.')), 403);
        }
        if (!self::is_supported_server()) {
            wp_send_json_error(array('message' => tamrank__('htaccess_unsupported_body', 'This server does not use .htaccess files, so the editor is disabled.')), 400);
        }

        $index   = isset($_POST['index']) ? absint($_POST['index']) : -1;
        $backups = self::get_backups();
        if (!isset($backups[$index])) {
            wp_send_json_error(array('message' => tamrank__('htaccess_backup_gone', 'That backup no longer exists.')));
        }
        $restore = (string) $backups[$index]['content'];

        // The backup was valid when saved, but re-check defensively.
        $valid = self::validate_syntax($restore);
        if (is_wp_error($valid)) {
            wp_send_json_error(array('message' => $valid->get_error_message()));
        }

        // Baseline without a confirmation gate: restoring IS the recovery
        // path and must not hide behind dialogs when the site is down. The
        // same fail-safe commit below still guards the write.
        $baseline = self::probe();

        $previous = self::read();
        $had_file = ($previous !== null);
        self::push_backup($had_file ? $previous : '');

        if ($restore === '') {
            self::delete_file();
        } elseif (!self::write($restore)) {
            wp_send_json_error(array('message' => tamrank__('htaccess_write_failed', 'Could not write .htaccess. Check the file permissions on your server.')));
        }

        $after    = self::probe();
        $decision = self::commit_decision($baseline['state'], $after['state']);

        if ($decision === 'restore') {
            if ($had_file) {
                self::write($previous);
            } else {
                self::delete_file();
            }
            wp_send_json_error(array(
                'message'      => tamrank__('htaccess_restore_broke', 'Restoring that backup did not leave the site in a working state, so it was undone.'),
                'rolled_back'  => true,
                'content'      => $had_file ? $previous : '',
                'backups_html' => self::backups_html(),
            ));
        }

        $payload = array(
            'message'      => tamrank__('htaccess_restored_ok', 'Backup restored.'),
            'content'      => $restore,
            'backups_html' => self::backups_html(),
        );
        if ($after['state'] === 'broken') {
            $payload['warning'] = tamrank__('htaccess_still_broken_kept', 'Your site still returns a server error, as it did before this change. The new rules were kept; you can restore a backup below.');
        } elseif ($after['state'] !== 'ok') {
            $payload['warning'] = tamrank__('htaccess_kept_unverified', 'Unverified: the automatic self-check cannot run on this host, so TamRank cannot confirm your site still works. Check it in a new tab now; if it is broken, restore the file via your hosting file manager or FTP.');
        }
        wp_send_json_success($payload);
    }

    /* ===================================================================
     * AJAX: browser second observer
     * =================================================================== */

    /**
     * After a kept save, the admin's browser fetches the homepage itself and
     * reports what it saw. A server-side firewall can blind the loopback
     * probe, but it cannot blind the browser that is already on the site.
     *
     * Safety property: this endpoint can only ADD protection. The keep
     * decision was already made synchronously inside the save request, so a
     * closed tab (no report, token expires) leaves that decided state — it
     * can never CREATE an unsafe one. A 'broken' report restores the
     * pre-save file; the token is single-use and expires after two minutes,
     * so a stale tab cannot revert a later save.
     */
    public function ajax_client_verify()
    {
        ignore_user_abort(true);

        check_ajax_referer(self::NONCE_ACTION, 'nonce');

        if (!self::current_user_can_edit()) {
            wp_send_json_error(array('message' => tamrank__('htaccess_unauthorized', 'You are not allowed to edit this file.')), 403);
        }
        if (self::is_editing_disabled()) {
            wp_send_json_error(array('message' => tamrank__('htaccess_disabled_by_config', 'File editing is disabled on this site (DISALLOW_FILE_EDIT or DISALLOW_FILE_MODS in wp-config.php), so the .htaccess editor is turned off.')), 403);
        }
        if (!self::is_supported_server()) {
            wp_send_json_error(array('message' => tamrank__('htaccess_unsupported_body', 'This server does not use .htaccess files, so the editor is disabled.')), 400);
        }

        $token   = isset($_POST['token']) ? sanitize_text_field(wp_unslash($_POST['token'])) : '';
        $verdict = isset($_POST['verdict']) ? sanitize_key(wp_unslash($_POST['verdict'])) : '';

        $stored = get_transient(self::VERIFY_TRANSIENT);
        if (!is_array($stored) || empty($stored['token']) || $token === '' || !hash_equals((string) $stored['token'], $token)) {
            wp_send_json_error(array('expired' => true));
        }
        delete_transient(self::VERIFY_TRANSIENT); // single use, whatever the verdict

        if ($verdict !== 'broken') {
            wp_send_json_success(array(
                'verified' => true,
                'message'  => tamrank__('htaccess_browser_verify_ok', 'Browser check passed: your site loads.'),
            ));
        }

        // The browser could not load the site after the save → restore the
        // pre-save file. Back up the current state first: every write stays
        // reversible, including this one.
        $current = self::read();
        self::push_backup($current === null ? '' : $current);
        if (!empty($stored['had_file'])) {
            self::write((string) $stored['previous']);
        } else {
            self::delete_file();
        }
        $recheck = self::probe();

        $message = tamrank__('htaccess_browser_restored', 'Your browser could not load the site after this change, so the previous version was restored automatically. Your edit is still in the editor below.');
        if ($recheck['state'] === 'broken') {
            $message .= ' ' . tamrank__('htaccess_rollback_still_broken', 'Warning: the site still returns an error after restoring. The cause may lie elsewhere.');
        }
        wp_send_json_success(array(
            'restored'     => true,
            'content'      => !empty($stored['had_file']) ? (string) $stored['previous'] : '',
            'backups_html' => self::backups_html(),
            'message'      => $message,
        ));
    }

    /* ===================================================================
     * Rendering
     * =================================================================== */

    /**
     * Render the backup list (also returned by AJAX to refresh in place).
     *
     * @return string
     */
    public static function backups_html()
    {
        $backups = self::get_backups();
        if (empty($backups)) {
            return '<p class="tr-htaccess-no-backups">' . esc_html(tamrank__('htaccess_no_backups', 'No backups yet. One is created automatically before your first save.')) . '</p>';
        }

        $date_fmt = get_option('date_format', 'Y-m-d') . ' ' . get_option('time_format', 'H:i');

        ob_start();
        foreach ($backups as $index => $backup) {
            $when  = isset($backup['time']) ? wp_date($date_fmt, (int) $backup['time']) : '';
            $bytes = isset($backup['bytes']) ? (int) $backup['bytes'] : 0;
            ?>
            <div class="tr-backup-row">
                <div class="tr-backup-row__meta">
                    <span class="tr-backup-row__when"><?php echo esc_html($when); ?></span>
                    <span class="tr-backup-row__sub">
                        <?php
                        printf(
                            /* translators: %s = human-readable file size */
                            esc_html(tamrank__('htaccess_backup_meta', 'before save · %s')),
                            esc_html(size_format(max($bytes, 0)))
                        );
                        ?>
                    </span>
                </div>
                <button type="button" class="tr-btn tr-btn--secondary tr-btn--tiny js-htaccess-restore"
                    data-index="<?php echo esc_attr($index); ?>">
                    <?php echo esc_html(tamrank__('htaccess_restore', 'Restore')); ?>
                </button>
            </div>
            <?php
        }
        return ob_get_clean();
    }

    /**
     * Render the whole card. Called from settings-page.php inside the
     * #technical tab.
     */
    public static function render_card()
    {
        // Multisite: de .htaccess is één netwerk-breed bestand; subsite-admins
        // krijgen de kaart helemaal niet (geen teaser), alleen super-admins.
        if (is_multisite() && !is_super_admin()) {
            return;
        }

        $supported   = self::is_supported_server();
        $disabled    = self::is_editing_disabled();
        $server      = self::server_label();
        $server_fam  = self::detect_server();
        $content     = self::read();
        $content     = ($content === null) ? '' : $content;
        $nonce       = wp_create_nonce(self::NONCE_ACTION);
        ?>
        <div class="tr-card" id="tr-htaccess-card"
            data-nonce="<?php echo esc_attr($nonce); ?>"
            data-home-url="<?php echo esc_attr(esc_url(home_url('/'))); ?>"
            data-error-generic="<?php echo esc_attr(tamrank__('htaccess_request_failed', 'The request failed. Please try again.')); ?>"
            data-site-down="<?php echo esc_attr(tamrank__('htaccess_site_down_after_fail', 'The save request failed and your site does not load right now. If it stays down, restore the .htaccess by hand via your hosting file manager or FTP; the previous contents are shown below so you can copy them.')); ?>"
            data-manual-recovery="<?php echo esc_attr(tamrank__('htaccess_manual_recovery', 'Your site appears to be down and WordPress cannot be reached to restore it automatically. Restore the .htaccess by hand via your hosting file manager or FTP; the previous contents are shown below so you can copy them.')); ?>"
            data-restore-title="<?php echo esc_attr(tamrank__('htaccess_restore_title', 'Restore this backup?')); ?>"
            data-restore-label="<?php echo esc_attr(tamrank__('htaccess_restore', 'Restore')); ?>"
            data-confirm-restore="<?php echo esc_attr(tamrank__('htaccess_confirm_restore', 'Restore this backup? Your current .htaccess is backed up first, so this stays reversible.')); ?>">
            <div class="tr-card-header">
                <div>
                    <h2 class="tr-card-title">.htaccess <?php tamrank_e('htaccess_editor', 'Editor'); ?>
                        <?php if ($supported && !$disabled) : ?>
                            <?php /* Standing fact, not an event. As a badge in the heading it stops
                                     competing for attention with the result notice below it. */ ?>
                            <span class="tr-card-badge tr-card-badge--ok">
                                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M20 6L9 17l-5-5" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
                                <span aria-hidden="true"><?php echo esc_html($server); ?></span>
                                <span class="screen-reader-text"><?php
                                    printf(
                                        /* translators: %s = server software name (Apache / LiteSpeed) */
                                        esc_html(tamrank__('htaccess_active_notice', '%s detected: .htaccess is active on this server')),
                                        esc_html($server)
                                    );
                                ?></span>
                            </span>
                        <?php endif; ?>
                        <?php echo tamrank_render_info_tooltip(tamrank__('htaccess_advanced_tooltip', 'Advanced feature. The .htaccess file controls how your server handles requests; one wrong rule can take your site offline. Before anything is saved, TamRank checks that your site responds and parse-tests your rules on a copy in a sandbox folder. After saving it checks your site again and puts the previous version back unless the site clearly still works. On hosts where these checks cannot run, TamRank tells you so and asks for confirmation first.'), array('size' => 'lg')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- returns escaped, plugin-generated tooltip markup (helper escapes text + attributes internally) ?></h2>
                    <p class="tr-card-subtitle">
                        <?php tamrank_e('htaccess_editor_desc', "Edit your site's Apache server rules directly from WordPress"); ?>
                    </p>
                </div>
            </div>
            <div class="tr-card-body">

                <?php if ($disabled) : ?>

                    <div class="tr-server-status tr-server-status--warn">
                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
                        <?php tamrank_e('htaccess_disabled_by_config', 'File editing is disabled on this site (DISALLOW_FILE_EDIT or DISALLOW_FILE_MODS in wp-config.php), so the .htaccess editor is turned off.'); ?>
                    </div>

                <?php elseif ($supported) : ?>

                    <?php // Server status now lives as a badge in the card heading. ?>

                    <!-- Read view -->
                    <div id="tr-htaccess-read">
                        <div id="tr-htaccess-read-body">
                            <?php if ($content === '') : ?>
                                <p class="tr-htaccess-empty">
                                    <?php tamrank_e('htaccess_empty', 'No .htaccess file yet. Open the editor to create one.'); ?>
                                </p>
                            <?php else : ?>
                                <pre class="tr-code-block"><code id="tr-htaccess-preview"><?php echo esc_html($content); ?></code></pre>
                            <?php endif; ?>
                        </div>
                        <div class="tr-pills" style="justify-content: flex-end; margin-top: 14px;">
                            <button type="button" class="tr-btn tr-btn--primary" id="tr-htaccess-edit-btn">
                                <?php tamrank_e('htaccess_edit_btn', 'Edit .htaccess'); ?>
                            </button>
                        </div>
                    </div>

                    <!-- Edit view -->
                    <div id="tr-htaccess-edit" hidden>
                        <?php // The long warning that used to sit here repeated the info tooltip
                              // almost verbatim. One quiet line under the buttons instead, where
                              // it is read at the moment of deciding rather than skipped on entry. ?>

                        <textarea id="tr-htaccess-textarea" class="tr-textarea tr-code-textarea"
                            spellcheck="false" rows="16"
                            data-empty-label="<?php echo esc_attr(tamrank__('htaccess_empty', 'No .htaccess file yet. Open the editor to create one.')); ?>"><?php echo esc_textarea($content); ?></textarea>

                        <div id="tr-htaccess-result" class="tr-syntax-result" hidden></div>

                        <div class="tr-editor-actions">
                            <button type="button" class="tr-btn tr-btn--secondary" id="tr-htaccess-cancel-btn">
                                <?php tamrank_e('cancel', 'Cancel'); ?>
                            </button>
                            <button type="button" class="tr-btn tr-btn--primary" id="tr-htaccess-save-btn">
                                <?php tamrank_e('htaccess_save_btn', 'Check & save'); ?>
                            </button>
                        </div>

                        <p class="tr-htaccess-hint">
                            <?php tamrank_e('htaccess_edit_hint', 'A wrong rule can take your site offline. TamRank backs up first and puts the old version back if your site stops responding.'); ?>
                        </p>

                        <?php // Collapsed: rarely needed, and expanded it filled more height than
                              // the editor itself. Count is the real number, not the cap. ?>
                        <details class="tr-backup-panel">
                            <?php // The template travels with the element so the JS can recount
                                  // after a save; the list is re-rendered, the summary is not. ?>
                            <summary class="tr-backup-panel__title" id="tr-htaccess-backups-summary"
                                data-label-template="<?php echo esc_attr(tamrank__('htaccess_backups_summary', 'Backups (%d)')); ?>">
                                <?php
                                printf(
                                    /* translators: %d = number of stored backups */
                                    esc_html(tamrank__('htaccess_backups_summary', 'Backups (%d)')),
                                    count(self::get_backups())
                                );
                                ?>
                            </summary>
                            <div id="tr-htaccess-backups"><?php echo self::backups_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped in backups_html() ?></div>
                        </details>
                    </div>

                <?php else : ?>

                    <div class="tr-server-status tr-server-status--warn">
                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
                        <?php
                        printf(
                            /* translators: %s = server software name (e.g. nginx) */
                            esc_html(tamrank__('htaccess_ignored_notice', 'Your server runs %s: .htaccess is ignored here')),
                            esc_html($server)
                        );
                        ?>
                    </div>
                    <div class="tr-htaccess-callout tr-htaccess-callout--info">
                        <svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
                        <p>
                            <?php
                            if ($server_fam === 'nginx') {
                                tamrank_e('htaccess_nginx_body', 'On nginx servers a .htaccess file has no effect: the web server does not read it. The editor is therefore disabled. Manage server rules through your hosting panel or your nginx configuration.');
                            } else {
                                tamrank_e('htaccess_unsupported_body', 'This server does not use .htaccess files, so the editor is disabled.');
                            }
                            ?>
                        </p>
                    </div>

                    <?php if ($content !== '') : ?>
                        <div class="tr-htaccess-readonly">
                            <p class="tr-field-hint" style="margin-bottom:8px;">
                                <?php tamrank_e('htaccess_readonly_label', 'Current file (read-only, ignored on this server):'); ?>
                            </p>
                            <pre class="tr-code-block"><code><?php echo esc_html($content); ?></code></pre>
                        </div>
                    <?php endif; ?>

                <?php endif; ?>

            </div>
        </div>
        <?php
        if ($supported) {
            self::print_inline_script();
        }
    }

    /**
     * Inline JS for the editor (attached to the already-enqueued tam-rank-admin
     * handle, mirroring the robots.txt / sitemap cards on this page).
     */
    private static function print_inline_script()
    {
        ob_start();
        ?>
        jQuery(function ($) {
            var $card = $('#tr-htaccess-card');
            if (!$card.length) { return; }

            var ajaxUrl = (window.tamRank && window.tamRank.ajaxUrl) ? window.tamRank.ajaxUrl : window.ajaxurl;
            var nonce   = $card.data('nonce');
            var homeUrl = $card.data('home-url') || (window.location.origin + '/');

            var $read   = $('#tr-htaccess-read');
            var $edit   = $('#tr-htaccess-edit');
            var $ta     = $('#tr-htaccess-textarea');
            var $result = $('#tr-htaccess-result');

            // Last content known to be on disk while the site loaded — handed
            // to the admin for copy-paste when only manual recovery is left.
            var lastGoodContent = $('#tr-htaccess-preview').length ? $('#tr-htaccess-preview').text() : '';

            function showResult(type, message) {
                $result
                    .removeClass('tr-syntax-result--ok tr-syntax-result--err tr-syntax-result--warn')
                    .addClass(type === 'ok' ? 'tr-syntax-result--ok' : (type === 'warn' ? 'tr-syntax-result--warn' : 'tr-syntax-result--err'))
                    .html(message)
                    .prop('hidden', false);
            }
            function clearResult() { $result.prop('hidden', true).empty(); }

            function appendRecoveryContent(content) {
                if (!content) { return; }
                $result.append($('<pre class="tr-code-block" style="margin-top:8px;"></pre>').text(content));
            }

            function refreshReadBody(content) {
                var $body = $('#tr-htaccess-read-body').empty();
                lastGoodContent = content || '';
                if (!content) {
                    $body.append($('<p class="tr-htaccess-empty"></p>').text($ta.attr('data-empty-label') || ''));
                    return;
                }
                var $pre = $('<pre class="tr-code-block"></pre>');
                var $code = $('<code id="tr-htaccess-preview"></code>').text(content);
                $pre.append($code);
                $body.append($pre);
            }

            // The browser's own view of the site — a server-side firewall can
            // blind the PHP loopback, but not the browser already on the site.
            function browserProbe() {
                var bust = (homeUrl.indexOf('?') > -1 ? '&' : '?') + 'tamrank_htaccess_selfcheck=' + Date.now();
                return fetch(homeUrl + bust, { cache: 'no-store', credentials: 'omit', redirect: 'follow' })
                    .then(function (r) { return r.status >= 500 ? 'broken' : 'ok'; })
                    .catch(function () { return 'unreachable'; });
            }

            // Re-render the backup list AND recount the collapsed summary. The
            // server only sends the list markup, so without the recount the
            // "Backups (n)" label would keep showing a stale number.
            function setBackups(html) {
                if (!html) { return; }
                var $list = $('#tr-htaccess-backups');
                $list.html(html);
                var $summary = $('#tr-htaccess-backups-summary');
                var tpl = $summary.data('label-template');
                if (tpl) {
                    $summary.text(String(tpl).replace('%d', $list.find('.tr-backup-row').length));
                }
            }

            // Nothing is broken when this dialog appears — we stopped the save
            // before it could break anything. So: caution amber rather than
            // alarm red, and the hierarchy flipped, because going back to the
            // editor is the right answer far more often than saving anyway.
            function askConfirm(title, message, confirmLabel, detail, cancelLabel) {
                if (window.TamRank && window.TamRank.confirm) {
                    return window.TamRank.confirm({
                        title: title,
                        message: message,
                        detail: detail || '',
                        confirmLabel: confirmLabel,
                        cancelLabel: cancelLabel || '',
                        variant: 'caution',
                        recommend: 'cancel'
                    });
                }
                return Promise.resolve(window.confirm(title + '\n\n' + message));
            }

            // Second observer: report what this browser sees to the token-
            // guarded endpoint, which may restore. Can only ADD safety — no
            // report just leaves the server-side decision in place.
            function runBrowserVerify(token, baselineB, baseMessage, kind, prevContent) {
                browserProbe().then(function (afterB) {
                    var verdict = null;
                    if (afterB === 'broken') { verdict = 'broken'; }
                    else if (afterB === 'unreachable' && baselineB === 'ok') { verdict = 'broken'; }
                    else if (afterB === 'ok') { verdict = 'ok'; }
                    if (!verdict) { return; } // observer blind — token simply expires
                    $.post(ajaxUrl, {
                        action:  'tamrank_htaccess_client_verify',
                        nonce:   nonce,
                        token:   token,
                        verdict: verdict
                    }).done(function (resp) {
                        var data = (resp && resp.success && resp.data) || {};
                        if (data.restored) {
                            showResult('err', data.message);
                            refreshReadBody(data.content); // editor keeps the draft
                            setBackups(data.backups_html);
                        } else if (data.verified && kind === 'warn') {
                            // Upgrade an unverified save with the browser's positive signal.
                            showResult('warn', baseMessage + ' ' + data.message);
                        }
                    }).fail(function () {
                        if (verdict === 'broken') {
                            // Site down AND WordPress unreachable: nothing can
                            // restore automatically any more. Hand over the
                            // previous contents for FTP repair.
                            showResult('err', $card.data('manual-recovery') || $card.data('error-generic') || 'Error');
                            appendRecoveryContent(prevContent);
                        }
                    });
                });
            }

            function doSave(flags) {
                var $btn = $('#tr-htaccess-save-btn');
                clearResult();
                $btn.prop('disabled', true).addClass('is-loading');
                var draft    = $ta.val();
                var prevGood = lastGoodContent;
                browserProbe().then(function (baselineB) {
                    var payload = $.extend({
                        action:  'tamrank_htaccess_save',
                        nonce:   nonce,
                        content: draft
                    }, flags || {});
                    $.post(ajaxUrl, payload).done(function (resp) {
                        if (resp && resp.success) {
                            var data = resp.data || {};
                            var kind = data.warning ? 'warn' : 'ok';
                            var msg  = data.message + (data.warning ? ' ' + data.warning : '');
                            showResult(kind, msg);
                            if (typeof data.content !== 'undefined') {
                                $ta.val(data.content);
                                refreshReadBody(data.content);
                            }
                            setBackups(data.backups_html);
                            if (data.verify_token) {
                                runBrowserVerify(data.verify_token, baselineB, msg, kind, prevGood);
                            }
                        } else {
                            var d = (resp && resp.data) || {};
                            if (d.needs_confirmation) {
                                askConfirm(d.confirm_title || '', d.message || '', d.confirm_label || 'Save anyway', d.detail || '', d.cancel_label || '').then(function (ok) {
                                    if (ok) { doSave($.extend({}, flags, { confirm_risky: 1 })); }
                                });
                                return;
                            }
                            if (d.can_force) {
                                // Keep the main story at two paragraphs: what
                                // went wrong and what we did. The force caveat
                                // and the diagnostics both belong one step back,
                                // in the muted line under it.
                                var forceDetail = [d.confirm_message, d.detail].filter(Boolean).join(' ');
                                askConfirm(d.confirm_title || '', d.message || '', d.confirm_label || 'Save anyway', forceDetail, d.cancel_label || '').then(function (ok) {
                                    if (ok) { doSave($.extend({}, flags, { force_preflight: 1 })); }
                                });
                                return;
                            }
                            showResult('err', d.message || 'Error');
                            if (d.rolled_back && typeof d.content !== 'undefined') {
                                // Restored file to the read view; the editor
                                // keeps the draft so nothing typed is lost.
                                refreshReadBody(d.content);
                            }
                            setBackups(d.backups_html);
                        }
                    }).fail(function () {
                        // The save request itself died (host killed it, or the
                        // connection dropped). Look at the site from here to
                        // tell the admin what actually happened.
                        browserProbe().then(function (st) {
                            if (st === 'ok') {
                                showResult('err', $card.data('error-generic') || 'Request failed');
                            } else {
                                showResult('err', $card.data('site-down') || $card.data('error-generic') || 'Request failed');
                                appendRecoveryContent(prevGood);
                            }
                        });
                    }).always(function () {
                        $btn.prop('disabled', false).removeClass('is-loading');
                    });
                });
            }

            // Open editor
            $('#tr-htaccess-edit-btn').on('click', function () {
                clearResult();
                $read.prop('hidden', true);
                $edit.prop('hidden', false);
                $ta.trigger('focus');
            });

            // Close editor (discard on-screen changes; server file is untouched)
            $('#tr-htaccess-cancel-btn').on('click', function () {
                clearResult();
                $edit.prop('hidden', true);
                $read.prop('hidden', false);
            });

            // Save
            $('#tr-htaccess-save-btn').on('click', function () { doSave({}); });

            // Restore a backup (delegated — the list is re-rendered on save)
            $('#tr-htaccess-backups').on('click', '.js-htaccess-restore', function () {
                var index = $(this).data('index');
                var $btn  = $(this);
                askConfirm(
                    $card.data('restore-title') || '',
                    $card.data('confirm-restore') || '',
                    $card.data('restore-label') || 'Restore'
                ).then(function (ok) {
                    if (!ok) { return; }
                    $btn.prop('disabled', true);
                    clearResult();
                    $.post(ajaxUrl, {
                        action: 'tamrank_htaccess_restore',
                        nonce:  nonce,
                        index:  index
                    }).done(function (resp) {
                        if (resp && resp.success) {
                            var data = resp.data || {};
                            if (data.warning) { showResult('warn', data.message + ' ' + data.warning); }
                            else { showResult('ok', data.message); }
                            if (typeof data.content !== 'undefined') {
                                $ta.val(data.content);
                                refreshReadBody(data.content);
                            }
                            setBackups(data.backups_html);
                        } else {
                            var d = (resp && resp.data) || {};
                            showResult('err', d.message || 'Error');
                            if (d.rolled_back && typeof d.content !== 'undefined') {
                                refreshReadBody(d.content);
                            }
                            setBackups(d.backups_html);
                        }
                    }).fail(function () {
                        showResult('err', $card.data('error-generic') || 'Request failed');
                    }).always(function () {
                        $btn.prop('disabled', false);
                    });
                });
            });
        });
        <?php
        wp_add_inline_script('tam-rank-admin', ob_get_clean());
    }
}
