<?php
/**
 * Visit tracker — server-side pageview recording on every front-end request.
 *
 * Hooks into `template_redirect` (only on real front-end views, after WP has
 * resolved the queried object). Inserts one row into wp_seoforge_visits per
 * pageview. Bot/admin/excluded-path filters apply.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Tracker {

    const COOKIE_NAME = 'seoforge_sid';

    public static function register( Seoforge_Loader $loader ) {
        $loader->add_action( 'template_redirect', __CLASS__, 'maybe_track', 99 );
        $loader->add_action( 'seoforge_cleanup_visits', __CLASS__, 'cleanup' );
        $loader->add_action( 'wp_enqueue_scripts',    __CLASS__, 'maybe_enqueue_beacon' );
    }

    /**
     * Front-end JS beacon — only emitted when tracking_mode is beacon|hybrid
     * and the same exclusion rules as server-side tracking apply.
     */
    public static function maybe_enqueue_beacon() {
        if ( ! Seoforge_Settings::get( 'tracking_enabled', true ) ) {
            return;
        }
        $mode = Seoforge_Settings::get( 'tracking_mode', 'server' );
        if ( 'beacon' !== $mode && 'hybrid' !== $mode ) {
            return;
        }
        if ( Seoforge_Settings::get( 'tracking_exclude_admins', true ) && current_user_can( 'edit_posts' ) ) {
            return;
        }
        if ( Seoforge_Utils::should_respect_dnt( Seoforge_Settings::get( 'tracking_respect_dnt', true ) ) ) {
            return;
        }
        // Mirror maybe_track()'s path/bot exclusion so the beacon script isn't
        // loaded (and outbound-click rows aren't insertable) on pages the site
        // owner explicitly excluded from tracking.
        $uri = Seoforge_Utils::safe_request_uri();
        if ( '' !== $uri ) {
            $excluded = (array) Seoforge_Settings::get( 'tracking_exclude_paths', array() );
            if ( Seoforge_Utils::matches_excluded_path( $uri, $excluded ) ) {
                return;
            }
        }
        if ( Seoforge_Settings::get( 'tracking_exclude_bots', true ) && Seoforge_Utils::is_bot( Seoforge_Utils::safe_user_agent() ) ) {
            return;
        }
        $session = isset( $_COOKIE[ self::COOKIE_NAME ] ) ? preg_replace( '/[^a-f0-9]/', '', sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) ) ) : '';
        wp_enqueue_script(
            'seoforge-tracker',
            SEOFORGE_URL . 'assets/js/tracker.js',
            array(),
            SEOFORGE_VERSION,
            true
        );
        wp_localize_script(
            'seoforge-tracker',
            'seoforge_track',
            array(
                'url'     => esc_url_raw( rest_url( 'seoforge/v1/beacon' ) ),
                'session' => $session,
                'token'   => Seoforge_Utils::beacon_token(),
            )
        );
    }

    public static function maybe_track() {
        if ( ! Seoforge_Settings::get( 'tracking_enabled', true ) ) {
            return;
        }
        if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
            return;
        }
        if ( is_preview() || is_customize_preview() || is_robots() || is_feed() || is_trackback() ) {
            return;
        }
        // Favicon requests (since WP 5.4, /favicon.ico with no static file is
        // routed through template_redirect) are not page views — skip them.
        if ( function_exists( 'is_favicon' ) && is_favicon() ) {
            return;
        }
        if ( Seoforge_Settings::get( 'tracking_exclude_admins', true ) && current_user_can( 'edit_posts' ) ) {
            return;
        }
        if ( Seoforge_Utils::should_respect_dnt( Seoforge_Settings::get( 'tracking_respect_dnt', true ) ) ) {
            return;
        }

        $uri = Seoforge_Utils::safe_request_uri();
        if ( '' === $uri ) {
            return;
        }
        // Static assets are not page views. Some (e.g. /favicon.ico) can still be
        // routed through WordPress; drop anything whose path ends in a known
        // static-file extension so they never pollute the Top-pages report.
        $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
        if ( Seoforge_Utils::is_static_asset_path( $path ) ) {
            return;
        }
        $excluded = (array) Seoforge_Settings::get( 'tracking_exclude_paths', array() );
        if ( Seoforge_Utils::matches_excluded_path( $uri, $excluded ) ) {
            return;
        }

        $ua = Seoforge_Utils::safe_user_agent();
        $is_bot = Seoforge_Utils::is_bot( $ua );
        if ( $is_bot && Seoforge_Settings::get( 'tracking_exclude_bots', true ) ) {
            return;
        }

        self::record_visit( $uri, $ua, $is_bot );
    }

    private static function record_visit( $uri, $ua, $is_bot ) {
        global $wpdb;

        $ip = Seoforge_Utils::get_real_ip();
        $ip_anon = (bool) Seoforge_Settings::get( 'privacy_anonymize_ip', true )
            ? Seoforge_Utils::anonymize_ip( $ip )
            : $ip;

        $visitor_hash = Seoforge_Utils::visitor_hash( $ip, $ua );
        $session_id   = self::get_or_create_session_id();

        $referrer_url = Seoforge_Utils::safe_referrer();
        $own_host     = strtolower( wp_parse_url( home_url( '/' ), PHP_URL_HOST ) );
        $ref          = Seoforge_Utils::classify_referrer( $referrer_url, $own_host );

        $utm = self::extract_utm();
        // UTM trumps referrer classification when present.
        $source_type = $ref['type'];
        $source_name = $ref['name'];
        if ( ! empty( $utm['utm_source'] ) ) {
            $source_type = ! empty( $utm['utm_medium'] ) ? sanitize_key( $utm['utm_medium'] ) : 'campaign';
            $source_name = sanitize_text_field( $utm['utm_source'] );
        }

        $ua_parsed = Seoforge_Utils::parse_ua( $ua );
        $store_ua  = (bool) Seoforge_Settings::get( 'privacy_store_user_agent', true ) ? $ua : '';
        $store_url = (bool) Seoforge_Settings::get( 'privacy_store_full_url', true );

        $post_id = 0;
        $title   = '';
        if ( is_singular() ) {
            $post = get_queried_object();
            if ( $post && isset( $post->ID ) ) {
                $post_id = (int) $post->ID;
                $title   = mb_substr( wp_strip_all_tags( get_the_title( $post_id ) ), 0, 255 );
            }
        } elseif ( is_archive() || is_home() || is_search() || is_404() ) {
            $title = mb_substr( wp_strip_all_tags( wp_get_document_title() ), 0, 255 );
        }

        // Resolve geo once. Free tier fills country (bundled IP table); Pro adds
        // region + city (MaxMind City). The legacy `seoforge_visit_country` filter
        // is kept so third-party overrides still work.
        $geo     = (array) apply_filters( 'seoforge_visit_geo', array( 'country' => '', 'region' => '', 'city' => '' ), $ip );
        $country = (string) apply_filters( 'seoforge_visit_country', isset( $geo['country'] ) ? (string) $geo['country'] : '', $ip );
        $region  = isset( $geo['region'] ) ? (string) $geo['region'] : '';
        $city    = isset( $geo['city'] ) ? (string) $geo['city'] : '';

        // First-visit detection: "new" if this visitor_hash has not been seen
        // before for this blog. The hash rotates daily when privacy_hash_rotate_daily
        // is on, so "new" is effectively per-day — fine for privacy-first analytics.
        $is_new = 1;
        if ( '' !== $visitor_hash ) {
            $seen = (int) $wpdb->get_var( $wpdb->prepare(
                "SELECT id FROM %i WHERE blog_id = %d AND visitor_hash = %s LIMIT 1",
                Seoforge_Utils::table( 'visits' ),
                Seoforge_Utils::current_blog_id(),
                $visitor_hash
            ) );
            $is_new = $seen ? 0 : 1;
        }

        $row = array(
            'blog_id'       => Seoforge_Utils::current_blog_id(),
            'session_id'    => $session_id,
            'visitor_hash'  => $visitor_hash,
            'post_id'       => $post_id,
            'url'           => $store_url ? home_url( $uri ) : '',
            'url_path'      => Seoforge_Utils::url_path( home_url( $uri ) ),
            'title'         => $title,
            'referrer_url'  => $store_url ? $referrer_url : '',
            'referrer_host' => $ref['host'],
            'source_type'   => $source_type,
            'source_name'   => mb_substr( $source_name, 0, 60 ),
            'search_term'   => $ref['search_term'],
            'utm_source'    => $utm['utm_source'],
            'utm_medium'    => $utm['utm_medium'],
            'utm_campaign'  => $utm['utm_campaign'],
            'utm_term'      => $utm['utm_term'],
            'utm_content'   => $utm['utm_content'],
            'ip_anon'       => $ip_anon,
            'country'       => mb_substr( $country, 0, 2 ),
            'region'        => '' !== $region ? mb_substr( $region, 0, 80 ) : null,
            'city'          => '' !== $city ? mb_substr( $city, 0, 120 ) : null,
            'user_agent'    => mb_substr( $store_ua, 0, 255 ),
            'device_type'   => $ua_parsed['device'],
            'browser'       => $ua_parsed['browser'],
            'os'            => $ua_parsed['os'],
            'is_bot'        => $is_bot ? 1 : 0,
            'duration_sec'  => 0,
            'is_new_visitor' => $is_new,
            'status_code'   => is_404() ? 404 : 200,
            'created_at'    => current_time( 'mysql', 1 ),
        );

        $wpdb->insert( Seoforge_Utils::table( 'visits' ), $row );
    }

    private static function get_or_create_session_id() {
        if ( isset( $_COOKIE[ self::COOKIE_NAME ] ) ) {
            $sid = preg_replace( '/[^a-f0-9]/', '', sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) ) );
            if ( 32 === strlen( $sid ) ) {
                return $sid;
            }
        }
        // 128-bit cryptographically-secure session id (32 hex chars — matches the
        // CHAR(32) column and the beacon's [a-f0-9] validation).
        try {
            $sid = bin2hex( random_bytes( 16 ) );
        } catch ( Exception $e ) {
            $sid = md5( uniqid( 'sf', true ) . wp_generate_password( 32, false, false ) );
        }
        // First-party session cookie only (expires on browser close); not a cross-site tracking cookie.
        // because the value is a server-side aggregation key, not a cross-site identifier.
        $params = array(
            'expires'  => 0,
            'path'     => COOKIEPATH ?: '/',
            'domain'   => COOKIE_DOMAIN ?: '',
            'secure'   => is_ssl(),
            'httponly' => true,
            'samesite' => 'Lax',
        );
        if ( ! headers_sent() ) {
            if ( PHP_VERSION_ID >= 70300 ) {
                setcookie( self::COOKIE_NAME, $sid, $params );
            } else {
                setcookie( self::COOKIE_NAME, $sid, 0, $params['path'], $params['domain'], $params['secure'], $params['httponly'] );
            }
        }
        $_COOKIE[ self::COOKIE_NAME ] = $sid;
        return $sid;
    }

    private static function extract_utm() {
        $keys = array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content' );
        $out  = array_fill_keys( $keys, null );
        foreach ( $keys as $k ) {
            if ( ! empty( $_GET[ $k ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
                $out[ $k ] = mb_substr( sanitize_text_field( wp_unslash( $_GET[ $k ] ) ), 0, 120 );
            }
        }
        return $out;
    }

    /**
     * Cleanup cron — drop visits older than retention, scoped to one blog
     * (defaults to the current one) so a Multisite cleanup never touches
     * another site's rows — see Seoforge_Tools::cleanup_now().
     */
    public static function cleanup( $blog_id = null ) {
        global $wpdb;
        $blog_id = null === $blog_id ? Seoforge_Utils::current_blog_id() : (int) $blog_id;
        $days = max( 1, (int) Seoforge_Settings::get( 'visit_retention_days', 365 ) );
        $cutoff = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS * $days );
        return $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE blog_id = %d AND created_at < %s", Seoforge_Utils::table( 'visits' ), $blog_id, $cutoff ) );
    }
}
