<?php
/**
 * REST API — read-only stats for in-admin widgets and (Pro) external dashboards.
 *
 * Routes (namespace `seoforge/v1`):
 *   GET /summary?days=30
 *   GET /timeseries?days=30
 *   GET /top-pages?hours=168&limit=20
 *   GET /top-sources?hours=168&limit=20
 *   GET /live      — last-5-minutes ticker
 *
 * Auth: same-origin admin nonce (`X-WP-Nonce`) — caller must be logged in
 * with `manage_options` / `manage_network_options`. Read-only routes only.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Rest {

    const NAMESPACE_V1 = 'seoforge/v1';

    public static function register( Seoforge_Loader $loader ) {
        $loader->add_action( 'rest_api_init', __CLASS__, 'register_routes' );
    }

    public static function register_routes() {
        $args_days = array(
            'days' => array( 'type' => 'integer', 'default' => 30, 'minimum' => 1, 'maximum' => 3650 ),
        );
        $args_hours = array(
            'hours' => array( 'type' => 'integer', 'default' => 168, 'minimum' => 1, 'maximum' => 24 * 3650 ),
            'limit' => array( 'type' => 'integer', 'default' => 20,  'minimum' => 1, 'maximum' => 100 ),
        );

        register_rest_route( self::NAMESPACE_V1, '/summary', array(
            'methods'             => 'GET',
            'permission_callback' => array( __CLASS__, 'permission' ),
            'args'                => $args_days,
            'callback'            => array( __CLASS__, 'route_summary' ),
        ) );
        register_rest_route( self::NAMESPACE_V1, '/timeseries', array(
            'methods'             => 'GET',
            'permission_callback' => array( __CLASS__, 'permission' ),
            'args'                => $args_days,
            'callback'            => array( __CLASS__, 'route_timeseries' ),
        ) );
        register_rest_route( self::NAMESPACE_V1, '/top-pages', array(
            'methods'             => 'GET',
            'permission_callback' => array( __CLASS__, 'permission' ),
            'args'                => $args_hours,
            'callback'            => array( __CLASS__, 'route_top_pages' ),
        ) );
        register_rest_route( self::NAMESPACE_V1, '/top-sources', array(
            'methods'             => 'GET',
            'permission_callback' => array( __CLASS__, 'permission' ),
            'args'                => $args_hours,
            'callback'            => array( __CLASS__, 'route_top_sources' ),
        ) );
        register_rest_route( self::NAMESPACE_V1, '/live', array(
            'methods'             => 'GET',
            'permission_callback' => array( __CLASS__, 'permission' ),
            'callback'            => array( __CLASS__, 'route_live' ),
        ) );
        // Public ingest endpoint — no auth, but rate-limited per IP.
        register_rest_route( self::NAMESPACE_V1, '/beacon', array(
            'methods'             => 'POST',
            'permission_callback' => '__return_true',
            'callback'            => array( __CLASS__, 'route_beacon' ),
        ) );
    }

    /**
     * Public ingest for the JS beacon. Updates the visit row's duration
     * and scroll depth, and writes outbound-link rows for click tracking.
     * Returns 204 on success — fast and cacheable-safe.
     */
    public static function route_beacon( $request ) {
        if ( ! Seoforge_Settings::get( 'tracking_enabled', true ) ) {
            return new WP_REST_Response( null, 204 );
        }
        // Same DNT check as Seoforge_Tracker::maybe_track()/maybe_enqueue_beacon()
        // (shared via Seoforge_Utils::should_respect_dnt(), not reimplemented) — a
        // visitor toggling DNT mid-session, or a beacon token localized on an
        // earlier page load before DNT was set, must not still get engagement/CWV/
        // outbound writes through this path. Respond exactly like a normal accepted
        // beacon (204, no error) so the JS never retries or leaks that it was dropped.
        if ( Seoforge_Utils::should_respect_dnt( Seoforge_Settings::get( 'tracking_respect_dnt', true ) ) ) {
            return new WP_REST_Response( null, 204 );
        }
        // Same-origin guard: legitimate browser beacons from our own pages always
        // send an Origin (or at least a Referer) header. Reject anything whose
        // host does not match this site — a missing header counts as invalid.
        if ( ! self::beacon_origin_ok( $request ) ) {
            self::log_rejected_beacon( 'origin_mismatch' );
            return new WP_REST_Response( null, 403 );
        }
        // Rotating token guard: raises the bar above spoofable Origin/Referer
        // headers — a scripted client must first load a page to obtain a valid
        // token from the localized tracker config.
        if ( ! Seoforge_Utils::beacon_token_valid( $request->get_param( 'token' ) ) ) {
            self::log_rejected_beacon( 'bad_token' );
            return new WP_REST_Response( null, 403 );
        }
        // Light per-IP rate limit — at most 20 hits per minute per anon IP. A real
        // page view emits only a couple of heartbeat/pagehide beacons per minute.
        $ip   = Seoforge_Utils::get_real_ip();
        $key  = 'seoforge_beacon_' . md5( $ip ) . '_' . gmdate( 'YmdHi' );
        $hits = (int) get_transient( $key );
        if ( $hits >= 20 ) {
            self::log_rejected_beacon( 'rate_limited' );
            return new WP_REST_Response( null, 429 );
        }
        set_transient( $key, $hits + 1, MINUTE_IN_SECONDS );

        $body = $request->get_json_params();
        if ( ! is_array( $body ) ) {
            return new WP_REST_Response( null, 204 );
        }

        $session  = isset( $body['session'] )  ? preg_replace( '/[^a-f0-9]/', '', (string) $body['session'] ) : '';
        $url_path = isset( $body['path'] )     ? mb_substr( esc_url_raw( $body['path'] ), 0, 255 ) : '';
        $duration = isset( $body['duration'] ) ? max( 0, min( 7200, (int) $body['duration'] ) ) : 0;
        $scroll   = isset( $body['scroll'] )   ? max( 0, min( 100, (int) $body['scroll'] ) ) : 0;
        $outbound = isset( $body['outbound'] ) ? esc_url_raw( $body['outbound'] ) : '';
        $event    = isset( $body['event'] )    ? sanitize_key( $body['event'] ) : 'heartbeat';
        // Core Web Vitals (field data), sent by the browser on pagehide.
        $lcp = isset( $body['lcp'] ) ? max( 0, min( 60000, (int) $body['lcp'] ) ) : 0;
        $cls = isset( $body['cls'] ) ? max( 0, min( 100000, (int) round( (float) $body['cls'] * 1000 ) ) ) : 0;
        $inp = isset( $body['inp'] ) ? max( 0, min( 60000, (int) $body['inp'] ) ) : 0;

        if ( 32 !== strlen( $session ) ) {
            return new WP_REST_Response( null, 204 );
        }

        global $wpdb;
        $tbl = Seoforge_Utils::table( 'visits' );

        if ( 'outbound' === $event && $outbound ) {
            // Outbound clicks are stored as synthetic visit rows so they show
            // up in source/page tables without a new schema.
            $wpdb->insert( $tbl, array(
                'blog_id'       => Seoforge_Utils::current_blog_id(),
                'session_id'    => $session,
                'visitor_hash'  => '',
                'post_id'       => 0,
                'url'           => '',
                'url_path'      => $url_path ?: '/',
                'title'         => mb_substr( 'outbound: ' . $outbound, 0, 255 ),
                'referrer_url'  => '',
                'referrer_host' => (string) wp_parse_url( $outbound, PHP_URL_HOST ),
                'source_type'   => 'outbound',
                'source_name'   => mb_substr( (string) wp_parse_url( $outbound, PHP_URL_HOST ), 0, 60 ),
                'search_term'   => '',
                'utm_source'    => null, 'utm_medium' => null, 'utm_campaign' => null,
                'utm_term'      => null, 'utm_content' => null,
                'ip_anon'       => Seoforge_Utils::anonymize_ip( $ip ),
                'country'       => '',
                'user_agent'    => '',
                'device_type'   => '', 'browser' => '', 'os' => '',
                'is_bot'        => 0,
                'duration_sec'  => 0,
                'status_code'   => 200,
                'created_at'    => current_time( 'mysql', 1 ),
            ) );
            return new WP_REST_Response( null, 204 );
        }

        // Heartbeat / pagehide: update the most recent visit row in this session
        // matching this URL path.
        $row_id = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT id FROM %i WHERE session_id = %s AND url_path = %s ORDER BY id DESC LIMIT 1",
            $tbl, $session, $url_path ?: '/'
        ) );
        if ( ! $row_id ) {
            return new WP_REST_Response( null, 204 );
        }
        $update = array( 'duration_sec' => $duration );
        if ( $scroll > 0 ) {
            $update['scroll_depth'] = min( 100, $scroll );
        }
        // GA4-style engaged session: >= 10s active time OR a deep scroll.
        if ( $duration >= 10 || $scroll >= 50 ) {
            $update['is_engaged'] = 1;
        }
        // Store Core Web Vitals when present (pagehide beacon).
        if ( $lcp > 0 ) { $update['lcp_ms'] = $lcp; }
        if ( $cls > 0 ) { $update['cls_x1000'] = $cls; }
        if ( $inp > 0 ) { $update['inp_ms'] = $inp; }
        $wpdb->update( $tbl, $update, array( 'id' => $row_id ) );
        return new WP_REST_Response( null, 204 );
    }

    /**
     * True when the request's Origin (preferred) or Referer host matches this
     * site's host. A missing/blank header is treated as invalid.
     */
    private static function beacon_origin_ok( $request ) {
        $origin = $request->get_header( 'origin' );
        $source = $origin ? $origin : $request->get_header( 'referer' );
        if ( empty( $source ) ) {
            return false;
        }
        $req_host  = strtolower( (string) wp_parse_url( $source, PHP_URL_HOST ) );
        $site_host = strtolower( (string) wp_parse_url( get_site_url(), PHP_URL_HOST ) );
        return $req_host && $site_host && $req_host === $site_host;
    }

    /**
     * Record a discarded beacon for later analysis. No PII: only the reason and
     * the current minute — never the raw IP, URL or user-agent.
     */
    private static function log_rejected_beacon( $reason ) {
        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            error_log( sprintf( '[seoforge] beacon rejected: %s @ %s', $reason, gmdate( 'Y-m-d H:i' ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
        }
    }

    public static function permission( $request ) {
        if ( current_user_can( 'manage_options' ) || current_user_can( 'manage_network_options' ) ) {
            return true;
        }
        return new WP_Error( 'forbidden', 'Authentication required.', array( 'status' => 401 ) );
    }

    public static function route_summary( $request ) {
        $days = (int) $request['days'];
        return rest_ensure_response( Seoforge_Stats::summary( $days * 24, Seoforge_Utils::stats_scope() ) );
    }

    public static function route_timeseries( $request ) {
        return rest_ensure_response( Seoforge_Stats::visits_daily( (int) $request['days'], Seoforge_Utils::stats_scope() ) );
    }

    public static function route_top_pages( $request ) {
        return rest_ensure_response( Seoforge_Stats::top_pages( (int) $request['hours'], (int) $request['limit'], Seoforge_Utils::stats_scope() ) );
    }

    public static function route_top_sources( $request ) {
        return rest_ensure_response( Seoforge_Stats::top_sources( (int) $request['hours'], (int) $request['limit'], Seoforge_Utils::stats_scope() ) );
    }

    public static function route_live( $request ) {
        return rest_ensure_response( Seoforge_Stats::live_visitors( Seoforge_Utils::stats_scope() ) );
    }
}
