<?php
/**
 * Rank Forge — Robots.txt Control
 *
 * Virtual robots.txt via WordPress filter. Auto-appends sitemap URL.
 * Admin textarea for custom rules.
 *
 * WordPress only serves the virtual robots.txt when no physical
 * `robots.txt` file exists in the webroot — otherwise the web server returns
 * the file directly and the `robots_txt` filter never runs. On hosts that
 * ship a default physical robots.txt (Hostinger is one of them) the settings
 * textarea appeared to do nothing. We now detect that file, mirror saved
 * content into it when possible, and surface a notice in Settings when we
 * can't (not writable, etc).
 *
 * FREE feature — available on all plans.
 *
 * @package RankForge
 * @since 1.2.0
 */

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

class RANKFORGE_Robots_Txt {
    private static $instance = null;
    public static function instance() { if (!self::$instance) self::$instance = new self(); return self::$instance; }

    /** Marker we embed in files we own, so we only overwrite our own content. */
    const MANAGED_MARKER = '# Managed by Rank Forge';

    private function __construct() {
        add_filter('robots_txt', [$this, 'custom_robots_txt'], 10, 2);
        add_action('template_redirect', [$this, 'maybe_render_robots_txt'], 0);
        add_action('init', [$this, 'maybe_repair_managed_file'], 20);
        // Mirror saved settings into the physical file when the filter is bypassed.
        add_action('update_option_rankforge_robots_txt', [$this, 'sync_physical_file'], 10, 2);
        add_action('add_option_rankforge_robots_txt',    [$this, 'sync_physical_file_added'], 10, 2);
    }

    /**
 * Filter robots.txt output (virtual path, no physical file).
 */
    public function custom_robots_txt($output, $public) {
        $output = $this->build_output(get_option('rankforge_robots_txt', ''), $public);
        return $output;
    }

    public function maybe_repair_managed_file() {
        $this->ensure_managed_file_current();
    }

    /**
 * Serve /robots.txt directly on installs where the core robots handler
 * does not fire for subdirectory paths. This keeps the public endpoint
 * working even when WordPress falls through to the theme 404 template.
 */
    public function maybe_render_robots_txt() {
        $request_path = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? '')), PHP_URL_PATH);
        $target_path  = wp_parse_url(home_url('/robots.txt'), PHP_URL_PATH);
        if (empty($request_path) || empty($target_path)) return;

        $request_path = rtrim($request_path, '/');
        $target_path  = rtrim($target_path, '/');
        if ($request_path !== $target_path) return;

        global $wp_query;
        if (isset($wp_query)) {
            $wp_query->is_404 = false;
        }
        status_header(200);
        nocache_headers();
        header('Content-Type: text/plain; charset=UTF-8');
        header('X-Robots-Tag: noindex');
        // Not HTML: plain-text robots.txt body sent with the text/plain
        // Content-Type header above and exit below. esc_html would
        // entity-encode the directive syntax (User-agent:, Disallow:, etc.)
        // and corrupt the file WordPress serves to crawlers.
        echo $this->build_output(get_option('rankforge_robots_txt', '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- non-HTML plain-text payload, see comment above
        exit;
    }

    /**
 * Build the final robots.txt body from custom content + WP public flag.
 */
    public function build_output($custom, $public = null) {
        if ($public === null) $public = get_option('blog_public');

        if ('0' === (string) $public) {
            $out = "User-agent: *\nDisallow: /\n";
        } elseif (!empty(trim((string) $custom))) {
            $out = $custom;
        } else {
            $out = self::get_default();
        }

        $sitemap_url = home_url('/sitemap_index.xml');
        $out = $this->normalize_managed_sitemap_line($out, $sitemap_url);
        if (stripos($out, 'sitemap:') === false) {
            $out = rtrim($out) . "\n\nSitemap: " . $sitemap_url . "\n";
        }
        return $out;
    }

    /**
 * Cloned sites can carry a managed robots.txt whose Sitemap line still
 * points at the source environment. Rewrite Rank Forge's sitemap-index line
 * to this site's home URL while preserving any unrelated custom Sitemap
 * entries the user may have added.
 */
    private function normalize_managed_sitemap_line($body, $sitemap_url) {
        $lines = preg_split('/\r\n|\r|\n/', (string) $body);
        if (!is_array($lines)) return (string) $body;

        $normalized = [];
        $wrote_current = false;
        foreach ($lines as $line) {
            if (preg_match('/^\s*Sitemap:\s*(\S+)\s*$/i', $line, $m)) {
                $path = wp_parse_url($m[1], PHP_URL_PATH);
                if (is_string($path) && basename($path) === 'sitemap_index.xml') {
                    if (!$wrote_current) {
                        $normalized[] = 'Sitemap: ' . $sitemap_url;
                        $wrote_current = true;
                    }
                    continue;
                }
            }
            $normalized[] = $line;
        }

        return implode("\n", $normalized);
    }

    /**
 * Get default robots.txt content (for admin textarea placeholder).
 */
    public static function get_default() {
        return "User-agent: *\nDisallow: /wp-admin/\nAllow: /wp-admin/admin-ajax.php\n";
    }

    /**
 * All candidate on-disk locations for robots.txt. On hosts where
 * WP is installed in a subfolder (public_html/wp/) the web server
 * DocumentRoot differs from ABSPATH — writing to ABSPATH creates
 * a file the browser never hits because /robots.txt maps to
 * DocumentRoot/robots.txt. We check and write to BOTH locations.
 *
 * Order: DocumentRoot first (browser-visible), ABSPATH second.
 */
    public static function physical_paths() {
        $paths = [];
        if (!empty($_SERVER['DOCUMENT_ROOT'])) {
            $doc = rtrim(sanitize_text_field(wp_unslash($_SERVER['DOCUMENT_ROOT'])), "/\\") . '/robots.txt';
            $paths[] = $doc;
        }
        $abs = rtrim(ABSPATH, "/\\") . '/robots.txt';
        if (!in_array($abs, $paths, true)) $paths[] = $abs;
        return $paths;
    }

    /**
 * The primary path users should see / edit. Prefers the file the
 * browser actually serves — DocumentRoot — but falls back to
 * ABSPATH when no file exists under DocumentRoot.
 */
    public static function physical_path() {
        foreach (self::physical_paths() as $p) {
            if (file_exists($p)) return $p;
        }
        // No file exists anywhere — return DocumentRoot so a fresh
        // write lands where the browser will actually see it.
        $paths = self::physical_paths();
        return $paths[0] ?? (rtrim(ABSPATH, "/\\") . '/robots.txt');
    }

    /** Does a physical file exist at ANY candidate location? */
    public static function physical_exists() {
        foreach (self::physical_paths() as $p) {
            if (file_exists($p)) return true;
        }
        return false;
    }

    /** Is the primary physical file (or its directory) writable by PHP? */
    public static function physical_writable() {
        $path = self::physical_path();
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Web-root file; WP_Filesystem fallback exists, direct path avoids FTP-cred prompt.
        if (file_exists($path)) return is_writable($path);
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Web-root file; WP_Filesystem fallback exists, direct path avoids FTP-cred prompt.
        return is_writable(dirname($path));
    }

    /** Was the primary physical file written by this plugin (contains our marker)? */
    public static function physical_managed() {
        foreach (self::physical_paths() as $path) {
            if (!file_exists($path)) continue;
            $head = @file_get_contents($path, false, null, 0, 256);
            if ($head === false) continue;
            // All existing files must be managed for the banner to show green.
            if (strpos($head, self::MANAGED_MARKER) === false) return false;
        }
        return self::physical_exists();
    }

    /**
 * HTTP-level snapshot of /robots.txt as served by the web server.
 * Cached for 60s to avoid hammering the site on every settings page load.
 * Returns ['body' => string, 'status' => int] or null on failure.
 */
    public static function http_snapshot($force = false) {
        $cache_key = 'rankforge_robots_http_snap';
        if (!$force) {
            $cached = get_transient($cache_key);
            if (is_array($cached)) return $cached;
        }
        $resp = wp_remote_get(home_url('/robots.txt'), [
            'timeout'   => 6,
            'sslverify' => false,
            'headers'   => ['Cache-Control' => 'no-cache'],
        ]);
        if (is_wp_error($resp)) return null;
        $snap = [
            'body'   => (string) wp_remote_retrieve_body($resp),
            'status' => (int) wp_remote_retrieve_response_code($resp),
        ];
        set_transient($cache_key, $snap, 60);
        return $snap;
    }

    /**
 * Fires when rankforge_robots_txt option is updated. Mirror content into
 * the physical file if one exists, or always mirror when safe to do so.
 */
    public function sync_physical_file($old_value, $value) {
        $this->write_if_possible($value);
    }

    public function sync_physical_file_added($option, $value) {
        $this->write_if_possible($value);
    }

    /**
 * Write managed content to the physical robots.txt when:
 * - a physical file already exists and we own it, OR
 * - admin explicitly requested a takeover, OR
 * - no physical file exists yet (we can safely create one)
 * Returns true on write success, false otherwise.
 */
    public function write_if_possible($custom, $force_takeover = false) {
        $body = self::MANAGED_MARKER . "\n" . $this->build_output($custom);
        $any_success = false;

        // (re-fix 2026-04-17): write to ALL candidate locations. When
        // WP is installed in a subfolder (public_html/wp/), ABSPATH differs
        // from DocumentRoot and the browser's /robots.txt lands at the
        // DocumentRoot file — not ABSPATH. Writing to only one silently
        // produced the symptom "settings don't match served file".
        foreach (self::physical_paths() as $path) {
            $exists = file_exists($path);

            if ($exists && !$force_takeover) {
                // Only overwrite files we already own (have our marker).
                $head = @file_get_contents($path, false, null, 0, 256);
                $is_managed = $head !== false && strpos($head, self::MANAGED_MARKER) !== false;
                if (!$is_managed) continue;
            }

            if ($this->write_to($path, $body, $exists)) {
                $any_success = true;
            }
        }

        // Invalidate HTTP snapshot cache so the admin panel shows fresh state.
        delete_transient('rankforge_robots_http_snap');

        return $any_success;
    }

    /**
 * Repair stale managed physical files in-place when the site has been
 * cloned. Unmanaged user/server files are never touched here.
 */
    public function ensure_managed_file_current($custom = null) {
        if (!self::physical_managed()) return false;
        $custom = $custom === null ? get_option('rankforge_robots_txt', '') : $custom;
        $expected = trim(self::MANAGED_MARKER . "\n" . $this->build_output($custom));
        $current  = [];

        foreach (self::physical_paths() as $path) {
            if (!file_exists($path)) continue;
            $body = @file_get_contents($path);
            if ($body !== false) $current[] = trim($body);
        }

        if ($current && count(array_unique($current)) === 1 && reset($current) === $expected) {
            return false;
        }

        return $this->write_if_possible($custom, true);
    }

    /**
 * Low-level write with the usual fallbacks. Returns true on success.
 * Shared by write_if_possible across multiple candidate paths.
 */
    private function write_to($path, $body, $exists) {
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Web-root file; WP_Filesystem fallback exists, direct path avoids FTP-cred prompt.
        $dir_writable  = is_writable(dirname($path));
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Web-root file; WP_Filesystem fallback exists, direct path avoids FTP-cred prompt.
        $file_writable = $exists ? is_writable($path) : $dir_writable;

        // Primary: direct write.
        if ($file_writable) {
            if (@file_put_contents($path, $body) !== false) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Web-root file; WP_Filesystem fallback exists, direct path avoids FTP-cred prompt.
                @chmod($path, 0644);
                return true;
            }
        }

        // Fallback 1: unlink + recreate (directory writable but file is not).
        if ($exists && $dir_writable) {
            wp_delete_file($path);
            if (!file_exists($path) && @file_put_contents($path, $body) !== false) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Web-root file; WP_Filesystem fallback exists, direct path avoids FTP-cred prompt.
                @chmod($path, 0644);
                return true;
            }
        }

        // Fallback 2: WP_Filesystem API (Flywheel / Pantheon style wrappers).
        if (function_exists('WP_Filesystem')) {
            global $wp_filesystem;
            if (!$wp_filesystem) {
                require_once ABSPATH . 'wp-admin/includes/file.php';
                if (!WP_Filesystem()) return false;
            }
            if ($wp_filesystem && $wp_filesystem->put_contents($path, $body, 0644)) {
                return true;
            }
        }

        return false;
    }

    /**
 * Delete every managed physical file we own. Returns true if at least one
 * file was removed.
 */
    public function remove_managed_file() {
        $any = false;
        foreach (self::physical_paths() as $path) {
            if (!file_exists($path)) continue;
            $head = @file_get_contents($path, false, null, 0, 256);
            if ($head !== false && strpos($head, self::MANAGED_MARKER) !== false) {
                wp_delete_file($path);
                if (!file_exists($path)) $any = true;
            }
        }
        delete_transient('rankforge_robots_http_snap');
        return $any;
    }
}
