<?php
/**
 * Iframe Bridge — lets the SailWP cloud dashboard embed this site in an
 * iframe, auto-login the editor, and exchange page-state via postMessage.
 *
 * Three responsibilities (built in this order):
 *   1. Header override — strip X-Frame-Options that WP / security plugins
 *      add, set Content-Security-Policy frame-ancestors so embedding from
 *      sailwp.com / ai.sailwp.com / app.sailwp.com is permitted.
 *   2. Signed-token auto-login — a one-shot ?_swcp_iframe=TOKEN query
 *      parameter that the dashboard hands the iframe; we verify the HMAC
 *      against a shared secret and set the auth cookie for the user.
 *   3. postMessage bridge JS — injected only when the page renders inside
 *      an iframe, reports navigation + edit-applied events to the parent.
 */
if (!defined('ABSPATH')) exit;

class SailWP_Copilot_Iframe_Bridge {

    /**
     * Origins allowed to embed this site. Anything not in this list is
     * blocked by CSP frame-ancestors — the cloud dashboard, and nothing
     * else.
     */
    const ALLOWED_ANCESTORS = [
        'https://sailwp.com',
        'https://www.sailwp.com',
        'https://app.sailwp.com',
        'https://ai.sailwp.com',
    ];

    /** Lifetime of an iframe token (seconds). Dashboard issues fresh ones per embed. */
    const TOKEN_MAX_AGE = 90;

    /** Replay-protection TTL — once a JTI has been used we lock it for this long. */
    const JTI_LOCK_TTL  = 300;

    /** Query parameter name carrying the dashboard-issued token. */
    const TOKEN_PARAM   = '_swcp_iframe';

    /** Session cookie that keeps the iframe in edit-mode across page navigations.
     *  Without this, every internal link click drops swcp_edit=1 and the inline
     *  editor disappears mid-session. Set when the dashboard token is consumed,
     *  read by fullpage.php and class-rail.php as a fallback to ?swcp_edit=1. */
    const EDIT_COOKIE   = 'swcp_iframe_edit';

    public function __construct() {
        // Remove WP core's own X-Frame-Options handlers — they set
        // SAMEORIGIN on /wp-admin/ and /wp-login.php and would otherwise
        // block the iframe even after our own header() calls if a later
        // hook re-asserts them.
        remove_action('admin_init', 'send_frame_options_header');
        remove_action('login_init', 'send_frame_options_header');

        // Front-end: send_headers fires right before output. Run at a
        // late priority so we override anything a security plugin set
        // earlier.
        add_action('send_headers', [$this, 'override_headers'], 9999);

        // Admin / login: same logic, different hooks (send_headers only
        // fires on the front-end).
        add_action('admin_init',   [$this, 'override_headers'], 9999);
        add_action('login_init',   [$this, 'override_headers'], 9999);

        // Filter the wp_headers array that WP itself emits (covers cases
        // where a plugin uses this filter to set X-Frame-Options rather
        // than raw header()).
        add_filter('wp_headers', [$this, 'filter_wp_headers'], 9999);

        // Force SameSite=None on auth/logged-in cookies when the bridge
        // is active — without this the cookie is dropped inside the
        // cross-origin iframe context. MUST be registered BEFORE
        // maybe_consume_token() below; that call invokes
        // wp_set_auth_cookie() which fires these actions synchronously,
        // so the handlers have to be hooked already.
        //
        // Order inside wp_set_auth_cookie():
        //   1. do_action('set_auth_cookie')      <- our handler runs first, sets SameSite=None cookies
        //   2. apply_filters('send_auth_cookies') <- we return false to suppress WP's default setcookie
        //   3. setcookie(...) (skipped because of filter)
        add_action('set_auth_cookie',      [$this, 'rewrite_auth_cookie'],      10, 6);
        add_action('set_logged_in_cookie', [$this, 'rewrite_logged_in_cookie'], 10, 6);
        add_filter('send_auth_cookies',    [$this, 'suppress_default_setcookie'], 99);

        // postMessage bridge JS — enqueued on every front-end page; the
        // script itself no-ops when not loaded inside an iframe.
        add_action('wp_enqueue_scripts', [$this, 'enqueue_bridge_script']);

        // Auto-login from the dashboard token. The bridge class is
        // instantiated from inside class-plugin.php's init() which
        // itself runs on plugins_loaded (priority 10), so a
        // hook-registration here would arrive too late — the
        // current dispatch of plugins_loaded is already past the
        // priority-1 slot. Call it synchronously instead: we're
        // already inside plugins_loaded, no output has been sent, the
        // auth cookie can still be set, and WP hasn't decided the
        // current user yet.
        $this->maybe_consume_token();

        // Iframe chrome: hide the SailWP plugin's own Co-pilot rail when
        // rendered inside the dashboard (the cloud dashboard provides its
        // own chat surface — two boatjes would be confusing). The WP
        // admin bar STAYS visible: users navigate themselves to whichever
        // page/post they want to edit (Joost decision 19 mei 20:05 —
        // simpler than wiring every navigate_to tool call through the
        // iframe).
        add_filter('body_class',               [$this, 'maybe_body_class']);
        add_filter('sailwp_copilot_rail_show', [$this, 'maybe_hide_rail']);
    }

    /**
     * Sec-Fetch-Dest is sent by all modern browsers and is the
     * cheapest, most reliable signal that this request is being
     * loaded into an iframe.
     */
    public static function is_iframe_request() {
        $hdr = isset($_SERVER['HTTP_SEC_FETCH_DEST']) ? strtolower((string) $_SERVER['HTTP_SEC_FETCH_DEST']) : '';
        return $hdr === 'iframe' || $hdr === 'frame';
    }

    /**
     * True when this request should activate the inline editor — either
     * because the URL carries ?swcp_edit=1 OR because we previously
     * stashed the iframe-edit session cookie at token-consume time.
     * Centralised so fullpage.php and class-rail.php stay in sync.
     */
    public static function is_edit_mode_requested() {
        if (isset($_GET['swcp_edit']) && (string) $_GET['swcp_edit'] === '1') return true;
        if (isset($_COOKIE[self::EDIT_COOKIE]) && (string) $_COOKIE[self::EDIT_COOKIE] === '1') return true;
        return false;
    }

    /** Tag <body> with swcp-in-iframe so theme/plugin CSS can react. */
    public function maybe_body_class($classes) {
        if (self::is_iframe_request()) {
            $classes[] = 'swcp-in-iframe';
        }
        return $classes;
    }

    /**
     * Filter the rail's should_render() result — falls through to the
     * existing capability check when not in iframe context.
     */
    public function maybe_hide_rail($show) {
        if (self::is_iframe_request()) return false;
        return $show;
    }

    /**
     * Front-end enqueue: load iframe-bridge.js + localize the
     * dashboard-origin allowlist so the script knows whose postMessages
     * to trust.
     */
    public function enqueue_bridge_script() {
        $path = SAILWP_COPILOT_DIR . 'assets/js/iframe-bridge.js';
        if (!file_exists($path)) return;
        $ver = (string) filemtime($path);
        wp_enqueue_script(
            'sailwp-iframe-bridge',
            SAILWP_COPILOT_URL . 'assets/js/iframe-bridge.js',
            [],
            $ver,
            true
        );
        wp_localize_script('sailwp-iframe-bridge', 'SailWPCopilotIframe', [
            'allowedOrigins' => self::ALLOWED_ANCESTORS,
        ]);
    }

    /**
     * Returns the CSP frame-ancestors directive value, including any
     * extras a site owner registered via filter (e.g. their own staging
     * dashboard).
     */
    public static function frame_ancestors() {
        $list = self::ALLOWED_ANCESTORS;
        /**
         * Filter the list of origins allowed to embed this site in an
         * iframe. Default is the SailWP cloud dashboard origins. Return
         * an array of full origin strings (scheme + host, no path).
         *
         * @param string[] $list
         */
        $list = apply_filters('sailwp_copilot_iframe_ancestors', $list);
        $list = array_values(array_filter(array_map('trim', (array) $list)));
        return implode(' ', $list);
    }

    /**
     * Strip X-Frame-Options and set CSP frame-ancestors.
     *
     * Called from send_headers (front-end) and admin_init / login_init
     * (admin + login screens). header_remove only works if the header
     * has not yet been flushed — at all three hooks WP has not yet
     * output anything.
     */
    public function override_headers() {
        if (headers_sent()) return;

        // 1. Drop any X-Frame-Options that's already been queued.
        header_remove('X-Frame-Options');

        // 2. Set / merge the frame-ancestors directive.
        $ancestors = self::frame_ancestors();
        if ($ancestors === '') return;

        $existing = $this->get_existing_csp();
        if ($existing === null) {
            header("Content-Security-Policy: frame-ancestors $ancestors", true);
            return;
        }

        // CSP already set by another plugin — merge: replace any
        // existing frame-ancestors directive, append ours if missing.
        $merged = $this->merge_frame_ancestors($existing, $ancestors);
        header("Content-Security-Policy: $merged", true);
    }

    /**
     * Filter WP's outgoing headers array (used by wp_headers filter).
     * Same intent as override_headers(): scrub X-Frame-Options, ensure
     * CSP frame-ancestors covers the dashboard.
     *
     * @param array<string,string> $headers
     * @return array<string,string>
     */
    public function filter_wp_headers($headers) {
        if (!is_array($headers)) return $headers;

        // Case-insensitive scrub of X-Frame-Options.
        foreach (array_keys($headers) as $k) {
            if (strcasecmp($k, 'X-Frame-Options') === 0) {
                unset($headers[$k]);
            }
        }

        $ancestors = self::frame_ancestors();
        if ($ancestors === '') return $headers;

        $cspKey = null;
        foreach (array_keys($headers) as $k) {
            if (strcasecmp($k, 'Content-Security-Policy') === 0) {
                $cspKey = $k;
                break;
            }
        }
        if ($cspKey === null) {
            $headers['Content-Security-Policy'] = "frame-ancestors $ancestors";
        } else {
            $headers[$cspKey] = $this->merge_frame_ancestors($headers[$cspKey], $ancestors);
        }
        return $headers;
    }

    /**
     * Find the CSP header value already queued via header(), if any.
     * Returns the value string, or null when not set.
     */
    private function get_existing_csp() {
        foreach (headers_list() as $line) {
            // headers_list returns "Name: value" — case-insensitive match.
            if (stripos($line, 'Content-Security-Policy:') === 0) {
                return trim(substr($line, strlen('Content-Security-Policy:')));
            }
        }
        return null;
    }

    /**
     * Take a CSP value and ensure frame-ancestors is set to (at minimum)
     * the dashboard origins. If the existing CSP already has a
     * frame-ancestors directive we replace it, otherwise we append.
     */
    private function merge_frame_ancestors($existing_csp, $ancestors) {
        $parts = array_filter(array_map('trim', explode(';', $existing_csp)));
        $found = false;
        foreach ($parts as $i => $p) {
            if (stripos($p, 'frame-ancestors') === 0) {
                $parts[$i] = "frame-ancestors $ancestors";
                $found = true;
                break;
            }
        }
        if (!$found) {
            $parts[] = "frame-ancestors $ancestors";
        }
        return implode('; ', $parts);
    }

    /* -----------------------------------------------------------------
     * Auto-login: validate the dashboard's signed token and log the user
     * in for the iframe session.
     * -----------------------------------------------------------------*/

    /**
     * Look for the dashboard token, validate it, sign the user in, and
     * redirect to the clean URL. Bails silently on every failure path
     * so a bad token simply leaves the user logged-out.
     */
    public function maybe_consume_token() {
        if (empty($_GET[self::TOKEN_PARAM])) return;
        if (headers_sent()) return;

        $token  = (string) $_GET[self::TOKEN_PARAM];
        $secret = self::get_shared_secret();
        if ($secret === '') return;

        $payload = self::verify_token($token, $secret);
        if (!is_array($payload)) return;

        // Lock the JTI to prevent replay. Transient = one shot, period.
        $jti = isset($payload['jti']) ? (string) $payload['jti'] : '';
        if ($jti === '') return;
        $lock_key = 'swcp_iframe_jti_' . md5($jti);
        if (get_transient($lock_key)) return; // already consumed
        set_transient($lock_key, 1, self::JTI_LOCK_TTL);

        // Resolve the target user. Payload carries either user_email or
        // user_login — try email first (cloud canonical), fall back to
        // login.
        $user = null;
        if (!empty($payload['user_email'])) {
            $user = get_user_by('email', sanitize_email($payload['user_email']));
        }
        if (!$user && !empty($payload['user_login'])) {
            $user = get_user_by('login', sanitize_user($payload['user_login']));
        }
        if (!$user || !user_can($user, 'edit_posts')) return;

        // Mark this request so the cookie rewriters know to apply
        // SameSite=None. Static flag because the action fires inside
        // wp_set_auth_cookie's call stack.
        self::$consuming_token = true;

        wp_set_current_user($user->ID, $user->user_login);
        wp_set_auth_cookie($user->ID, true, is_ssl());

        // Stick an edit-mode session cookie so every subsequent navigation
        // inside the iframe (link clicks, redirects, form posts) keeps the
        // inline editor on, without each URL needing ?swcp_edit=1. Session
        // cookie (expires=0) → dies when the user closes the browser, so
        // there is no lingering edit-mode leak in regular browsing.
        setcookie(self::EDIT_COOKIE, '1', [
            'expires'  => 0,
            'path'     => '/',
            'domain'   => COOKIE_DOMAIN,
            'secure'   => true,
            'httponly' => false, // iframe-bridge.js may read it for diagnostics
            'samesite' => 'None',
        ]);

        // Strip the token from the URL and redirect — the auth cookie
        // is now set, the iframe will land on the clean URL and the
        // editor chrome appears.
        //
        // Critical: browsers evaluate frame-ancestors / X-Frame-Options
        // on EVERY response in the redirect chain, including the 302.
        // wp_safe_redirect() exits before WP fires send_headers, so we
        // must set our headers manually here — otherwise the 302 only
        // carries nginx's X-Frame-Options: SAMEORIGIN and the browser
        // refuses to load the iframe before it can even follow the
        // redirect.
        $this->override_headers();
        $clean = self::strip_token_from_request_uri();
        wp_safe_redirect($clean, 302, 'SailWP-Iframe-Bridge');
        exit;
    }

    /**
     * Flag set during the brief window where we're consuming a token,
     * so the cookie-rewriter actions know to apply SameSite=None. WP
     * fires set_auth_cookie synchronously inside wp_set_auth_cookie() so
     * a static flag is sufficient.
     */
    private static $consuming_token = false;

    /**
     * Re-emit the auth cookie with SameSite=None;Secure so it survives
     * the cross-origin iframe context. WP's own setcookie call did not
     * include the samesite attribute, so the browser drops the cookie
     * in third-party contexts unless we overwrite it here.
     */
    public function rewrite_auth_cookie($cookie, $expire, $expiration, $user_id, $scheme, $token) {
        if (!self::$consuming_token) return;
        $name = ($scheme === 'secure_auth') ? SECURE_AUTH_COOKIE : AUTH_COOKIE;
        $base = [
            'expires'  => (int) $expire,
            'domain'   => COOKIE_DOMAIN,
            'secure'   => true,
            'httponly' => true,
            'samesite' => 'None',
        ];
        if (defined('PLUGINS_COOKIE_PATH')) {
            setcookie($name, $cookie, $base + ['path' => PLUGINS_COOKIE_PATH]);
        }
        if (defined('ADMIN_COOKIE_PATH')) {
            setcookie($name, $cookie, $base + ['path' => ADMIN_COOKIE_PATH]);
        }
    }

    /**
     * Filter to suppress WP's default setcookie() calls inside
     * wp_set_auth_cookie when we're consuming an iframe token —
     * otherwise WP appends its own SameSite-less cookies after ours
     * and the browser uses the wrong one in third-party context.
     */
    public function suppress_default_setcookie($send) {
        return self::$consuming_token ? false : $send;
    }

    /** Same as rewrite_auth_cookie() but for the logged-in cookie. */
    public function rewrite_logged_in_cookie($logged_in_cookie, $expire, $expiration, $user_id, $scheme, $token) {
        if (!self::$consuming_token) return;
        setcookie(LOGGED_IN_COOKIE, $logged_in_cookie, [
            'expires'  => (int) $expire,
            'path'     => defined('COOKIEPATH') ? COOKIEPATH : '/',
            'domain'   => COOKIE_DOMAIN,
            'secure'   => true,
            'httponly' => true,
            'samesite' => 'None',
        ]);
    }

    /**
     * The HMAC secret shared with the cloud dashboard. The license key
     * (= the dashboard's sites.site_token) is the per-site identifier
     * that both sides already know — no new key-distribution flow is
     * needed. Falls back to site_api_key for installs that have one,
     * then to the filter for sites that want to rotate independently.
     */
    public static function get_shared_secret() {
        $secret = '';
        if (class_exists('SailWP_Copilot_Plugin')) {
            $secret = (string) SailWP_Copilot_Plugin::get_license_key();
            if ($secret === '') {
                $secret = (string) SailWP_Copilot_Plugin::get_site_api_key();
            }
        }
        if ($secret === '') {
            $secret = (string) get_option('sailwp_copilot_license_key', '');
        }
        return (string) apply_filters('sailwp_copilot_iframe_secret', $secret);
    }

    /**
     * Verify a dashboard-issued token. Format:
     *   base64url(payload_json) . "." . base64url(hmac_sha256)
     * Returns the decoded payload array on success, null on any failure.
     */
    public static function verify_token($token, $secret) {
        if (!is_string($token) || strpos($token, '.') === false) return null;
        list($payload_b64, $sig_b64) = explode('.', $token, 2);
        if ($payload_b64 === '' || $sig_b64 === '') return null;

        $payload_json = self::base64url_decode($payload_b64);
        $sig          = self::base64url_decode($sig_b64);
        if ($payload_json === false || $sig === false) return null;

        $expected = hash_hmac('sha256', $payload_b64, $secret, true);
        if (!hash_equals($expected, $sig)) return null;

        $payload = json_decode($payload_json, true);
        if (!is_array($payload)) return null;

        $now = time();
        $iat = isset($payload['iat']) ? (int) $payload['iat'] : 0;
        $exp = isset($payload['exp']) ? (int) $payload['exp'] : 0;
        if ($exp <= $now) return null;
        if ($iat > 0 && ($now - $iat) > self::TOKEN_MAX_AGE) return null;

        return $payload;
    }

    /**
     * Issue a fresh token. Mostly used in tests / debug — the cloud
     * dashboard issues its own tokens in Node, but this lets the plugin
     * (or a CLI) round-trip end-to-end with the same format.
     *
     * @param array<string,mixed> $payload Extra payload fields.
     */
    public static function issue_token(array $payload = []) {
        $secret = self::get_shared_secret();
        if ($secret === '') return '';
        $now = time();
        $payload = array_merge([
            'iat' => $now,
            'exp' => $now + 60,
            'jti' => bin2hex(random_bytes(8)),
        ], $payload);
        $payload_b64 = self::base64url_encode(wp_json_encode($payload));
        $sig         = hash_hmac('sha256', $payload_b64, $secret, true);
        $sig_b64     = self::base64url_encode($sig);
        return $payload_b64 . '.' . $sig_b64;
    }

    /**
     * Return the request URI with the iframe token query parameter
     * removed, so the redirect lands on a clean URL.
     */
    private static function strip_token_from_request_uri() {
        $uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '/';
        $parts = explode('?', $uri, 2);
        $path  = $parts[0];
        $query = $parts[1] ?? '';
        if ($query === '') return $path;
        parse_str($query, $args);
        unset($args[self::TOKEN_PARAM]);
        $rebuilt = http_build_query($args);
        return $rebuilt === '' ? $path : $path . '?' . $rebuilt;
    }

    private static function base64url_encode($bin) {
        return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
    }

    private static function base64url_decode($s) {
        $pad = strlen($s) % 4;
        if ($pad) $s .= str_repeat('=', 4 - $pad);
        return base64_decode(strtr($s, '-_', '+/'), true);
    }
}
