<?php
/**
 * Nginx access-log reconciliation against wp_seoforge_visits.
 *
 * THE CENTRAL DESIGN POINT: `visits` counts what the tracker decided to
 * count — after bot filtering, admin/AJAX/REST exclusion, and static-asset
 * exclusion. A raw access log counts every request. Comparing the two
 * numbers directly would show a large "deviation" that means nothing and
 * would look alarming while being useless. So this class applies the SAME
 * exclusion rules the tracker applies, reusing the tracker's own shared code
 * (Seoforge_Utils::is_bot(), is_static_asset_path(), matches_excluded_path(),
 * is_admin_or_excluded_path()) rather than re-deriving the rules — the exact
 * mistake that let the analyzer/outbound pair drift apart before that was
 * factored out. See the class docblock note on is_admin_or_excluded_path()
 * for the one place this can only be an approximation: a raw access log
 * carries no is_admin()/wp_doing_ajax()/REST-context signal, and no
 * logged-in-session or DNT-header signal either, so those two tracker
 * exclusions (tracking_exclude_admins, tracking_respect_dnt) cannot be
 * reproduced from a log line at all — documented, not silently ignored.
 *
 * Streams the file line-by-line (fopen/fgets or gzopen/gzgets) so memory
 * stays flat regardless of file size — ranges can cover millions of lines.
 * Resumable via a byte offset + caller-held accumulator so a run can span
 * multiple requests without blowing PHP's execution time limit and without
 * any server-side state between chunks.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Log_Reconciler {

    const FORMAT_COMBINED     = 'combined';
    const FORMAT_COMBINED_XFF = 'combined_xff';

    /** Regex for the nginx "combined" log_format; the XFF variant just has one more trailing quoted field. */
    const LINE_REGEX = '/^(\S+) \S+ \S+ \[([^\]]+)\] "(\S*) (\S*)(?: \S+)?" (\d{3}) (\S+) "([^"]*)" "([^"]*)"(?: "([^"]*)")?/';

    /* ------------------------------------------------------------- parsing */

    /**
     * Pure: one raw log line -> parsed fields, or null if the line doesn't
     * match the expected format at all (malformed/truncated line — skipped,
     * not fatal, so one bad line in a multi-million-line file doesn't abort
     * the whole reconciliation).
     *
     * @return array{ip:string,time_local:string,method:string,path:string,status:int,referer:string,user_agent:string,xff:string}|null
     */
    public static function parse_line( $line, $format = self::FORMAT_COMBINED ) {
        $line = rtrim( (string) $line, "\r\n" );
        if ( '' === $line ) {
            return null;
        }
        if ( ! preg_match( self::LINE_REGEX, $line, $m ) ) {
            return null;
        }
        return array(
            'ip'         => $m[1],
            'time_local' => $m[2],
            'method'     => $m[3],
            'path'       => self::strip_query( $m[4] ),
            'status'     => (int) $m[5],
            'referer'    => isset( $m[7] ) ? $m[7] : '',
            'user_agent' => isset( $m[8] ) ? $m[8] : '',
            'xff'        => ( self::FORMAT_COMBINED_XFF === $format && isset( $m[9] ) ) ? $m[9] : '',
        );
    }

    private static function strip_query( $path ) {
        $q = strpos( $path, '?' );
        return false === $q ? $path : substr( $path, 0, $q );
    }

    /**
     * Pure: nginx's $time_local (e.g. "05/Aug/2026:14:23:01 +0200") -> UTC
     * epoch, or false if unparseable. The offset is taken from the line
     * itself, so this is correct regardless of the server's configured
     * timezone vs. WordPress's — no separate "server timezone" setting is
     * needed, because nginx already writes an absolute, unambiguous instant.
     */
    public static function line_timestamp( $time_local ) {
        $dt = DateTime::createFromFormat( 'd/M/Y:H:i:s O', (string) $time_local, new DateTimeZone( 'UTC' ) );
        return $dt ? $dt->getTimestamp() : false;
    }

    /**
     * Pure: best-effort real client IP for one parsed line. Only trusts the
     * X-Forwarded-For field when the configured format says it's present
     * (combined_xff) — never guesses at a field the log format doesn't
     * actually contain. Takes the left-most (closest-to-client) hop, same
     * convention as a reverse-proxy chain is normally read.
     */
    public static function resolve_ip( array $parsed, $format = self::FORMAT_COMBINED ) {
        if ( self::FORMAT_COMBINED_XFF === $format && '' !== $parsed['xff'] ) {
            $hops = explode( ',', $parsed['xff'] );
            $first = trim( $hops[0] );
            if ( '' !== $first ) {
                return $first;
            }
        }
        return $parsed['ip'];
    }

    /* --------------------------------------------------------- classification */

    /**
     * Pure: classify one parsed line against the SAME exclusion rules the
     * live tracker applies (see class docblock). Returns which layer, if
     * any, excludes it — 'static', 'admin', 'bot', or null (counted).
     * Layer order matches the report's requested presentation order: total
     * -> static -> admin/ajax -> bot -> like-for-like.
     */
    public static function classify_line( array $parsed, array $excluded_paths = array() ) {
        if ( Seoforge_Utils::is_static_asset_path( $parsed['path'] ) ) {
            return 'static';
        }
        if ( Seoforge_Utils::is_admin_or_excluded_path( $parsed['path'], $excluded_paths ) ) {
            return 'admin';
        }
        if ( Seoforge_Utils::is_bot( $parsed['user_agent'] ) ) {
            return 'bot';
        }
        return null;
    }

    /* ------------------------------------------------------------- streaming */

    /**
     * Line generator for a plain or gzipped log file. Never loads the file
     * into memory — one line in flight at a time. $offset seeks to a byte
     * position first (gzseek is sequential-only forward, which is fine here
     * since resumption always moves forward through the file).
     */
    public static function read_lines( $path, $offset = 0 ) {
        $is_gz  = ( '.gz' === substr( $path, -3 ) );
        $handle = $is_gz ? gzopen( $path, 'rb' ) : fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
        if ( ! $handle ) {
            return;
        }
        if ( $offset > 0 ) {
            $is_gz ? gzseek( $handle, $offset ) : fseek( $handle, $offset );
        }
        while ( ! ( $is_gz ? gzeof( $handle ) : feof( $handle ) ) ) {
            $line = $is_gz ? gzgets( $handle ) : fgets( $handle );
            if ( false === $line ) {
                break;
            }
            $pos = $is_gz ? gztell( $handle ) : ftell( $handle );
            yield array( 'line' => $line, 'offset' => $pos );
        }
        $is_gz ? gzclose( $handle ) : fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions
    }

    /* ---------------------------------------------------------- reconcile */

    /**
     * Stream $path, classify every line inside [$start_ts, $end_ts) (half-
     * open, matching Seoforge_History::fetch_range()'s convention), and
     * accumulate the layered counts. Bounded by a wall-clock budget (default
     * 20s, filterable via `seoforge_verify_reconcile_budget`) so one request
     * never risks PHP's max_execution_time on a multi-million-line file —
     * when the budget runs out mid-file, returns done=>false plus a byte
     * resume_offset the caller feeds back in as $offset on the next call,
     * along with the returned $result as the next call's $accumulator. No
     * state is kept server-side between chunks.
     *
     * @param string     $path
     * @param int        $start_ts       Inclusive, UTC epoch.
     * @param int        $end_ts         Exclusive, UTC epoch.
     * @param string     $format         self::FORMAT_*
     * @param array      $excluded_paths tracking_exclude_paths setting value.
     * @param int        $offset         Byte offset to resume from.
     * @param array|null $accumulator    Previous partial result to continue.
     * @param int        $budget_seconds Wall-clock budget for this call.
     * @return array{done:bool,resume_offset:int,total:int,after_static:int,after_admin:int,after_bot:int,counted:int,sample_by_minute_path:array}
     */
    public static function reconcile( $path, $start_ts, $end_ts, $format, array $excluded_paths, $offset = 0, $accumulator = null, $budget_seconds = 20 ) {
        $result = is_array( $accumulator ) ? $accumulator : self::empty_result();
        $deadline = microtime( true ) + max( 1, (int) $budget_seconds );
        $resume_offset = $offset;
        $done = true;

        foreach ( self::read_lines( $path, $offset ) as $entry ) {
            $resume_offset = $entry['offset'];
            if ( microtime( true ) >= $deadline ) {
                $done = false;
                break;
            }

            $parsed = self::parse_line( $entry['line'], $format );
            if ( null === $parsed ) {
                $result['malformed']++;
                continue;
            }
            $ts = self::line_timestamp( $parsed['time_local'] );
            if ( false === $ts || $ts < $start_ts || $ts >= $end_ts ) {
                continue; // outside the requested range — not counted at any layer.
            }

            $result['total']++;
            $layer = self::classify_line( $parsed, $excluded_paths );
            if ( 'static' === $layer ) {
                $result['excluded_static']++;
                continue;
            }
            $result['after_static']++;
            if ( 'admin' === $layer ) {
                $result['excluded_admin']++;
                continue;
            }
            $result['after_admin']++;
            if ( 'bot' === $layer ) {
                $result['excluded_bot']++;
                continue;
            }
            $result['counted']++;

            $minute = gmdate( 'Y-m-d H:i', $ts );
            $bucket = $minute . '|' . $parsed['path'];
            if ( count( $result['log_buckets'] ) < 5000 ) { // bounded — a sample, not a full index.
                $result['log_buckets'][ $bucket ] = ( isset( $result['log_buckets'][ $bucket ] ) ? $result['log_buckets'][ $bucket ] : 0 ) + 1;
            }
        }

        $result['done']          = $done;
        $result['resume_offset'] = $resume_offset;
        return $result;
    }

    private static function empty_result() {
        return array(
            'total'            => 0,
            'excluded_static'  => 0,
            'after_static'     => 0,
            'excluded_admin'   => 0,
            'after_admin'      => 0,
            'excluded_bot'     => 0,
            'counted'          => 0,
            'malformed'        => 0,
            'log_buckets'      => array(),
        );
    }

    /**
     * Compare the log's like-for-like count/buckets to the plugin's own
     * `visits` rows for the same range+blog, bucketed the same way
     * (minute+path). IP/session matching isn't reliable here on purpose —
     * `visits.ip_anon` is anonymized by design (privacy_anonymize_ip),
     * so an exact per-visitor join across the two sources would silently
     * assume more precision than the data actually has. Minute+path
     * bucketing is the coarsest join that's still diagnosable.
     *
     * @return array{plugin_count:int,deviation:int,sample_log_only:array,sample_plugin_only:array}
     */
    public static function compare_to_visits( array $log_result, $blog_id, $start_ts, $end_ts ) {
        global $wpdb;
        $rows = (array) $wpdb->get_results( $wpdb->prepare(
            "SELECT url_path, created_at FROM %i WHERE blog_id = %d AND created_at >= %s AND created_at < %s",
            Seoforge_Utils::table( 'visits' ), (int) $blog_id,
            gmdate( 'Y-m-d H:i:s', $start_ts ), gmdate( 'Y-m-d H:i:s', $end_ts )
        ), ARRAY_A );

        $plugin_buckets = array();
        foreach ( $rows as $row ) {
            $minute = gmdate( 'Y-m-d H:i', strtotime( (string) $row['created_at'] . ' UTC' ) );
            $bucket = $minute . '|' . $row['url_path'];
            $plugin_buckets[ $bucket ] = ( isset( $plugin_buckets[ $bucket ] ) ? $plugin_buckets[ $bucket ] : 0 ) + 1;
        }

        return self::build_comparison( $log_result, $plugin_buckets, count( $rows ) );
    }

    /** Pure: diff two minute+path bucket multisets into a small, diagnosable sample. */
    public static function build_comparison( array $log_result, array $plugin_buckets, $plugin_count ) {
        $log_buckets = isset( $log_result['log_buckets'] ) ? $log_result['log_buckets'] : array();
        $sample_log_only    = array();
        $sample_plugin_only = array();

        foreach ( $log_buckets as $bucket => $count ) {
            $plugin_n = isset( $plugin_buckets[ $bucket ] ) ? $plugin_buckets[ $bucket ] : 0;
            if ( $count > $plugin_n && count( $sample_log_only ) < 20 ) {
                $sample_log_only[] = $bucket;
            }
        }
        foreach ( $plugin_buckets as $bucket => $count ) {
            $log_n = isset( $log_buckets[ $bucket ] ) ? $log_buckets[ $bucket ] : 0;
            if ( $count > $log_n && count( $sample_plugin_only ) < 20 ) {
                $sample_plugin_only[] = $bucket;
            }
        }

        $counted = isset( $log_result['counted'] ) ? (int) $log_result['counted'] : 0;
        return array(
            'plugin_count'       => (int) $plugin_count,
            'deviation'          => $counted - (int) $plugin_count,
            'sample_log_only'    => $sample_log_only,
            'sample_plugin_only' => $sample_plugin_only,
        );
    }

    /* ---------------------------------------------------------------- path */

    /**
     * Resolve + validate the free-text configured log path (the
     * `verify_nginx_log_path` setting) before anything ever opens it.
     *
     * THREAT MODEL — who may reach this method at all matters as much as
     * what it checks. This setting is intentionally an arbitrary absolute
     * server path (nginx log locations vary too much to constrain to a
     * fixed base dir — that's the whole reason the setting exists), which
     * means letting anyone set it is equivalent to giving them read access
     * to any file the PHP-FPM user can read. On a single-site install, or
     * for a network super-admin / manage_network_options holder, that's an
     * acceptable trust level — they already have plugin-install (i.e.
     * arbitrary code execution) capability. On Multisite a plain SUBSITE
     * admin does NOT have that trust level relative to the rest of the
     * network, so the admin view (page-verify.php) never renders this field
     * for a subsite admin at all — only network admins / super admins can
     * see or set it (gated the same way Seoforge_Admin::scope_picker() gates
     * network-only UI, via Seoforge_Utils::can_view_network_scope()). A
     * subsite admin gets the upload-fallback path only (resolve_uploaded_path()
     * below), which never exposes an arbitrary server path.
     *
     * '..' is rejected outright before realpath() ever runs. A missing/
     * unreadable file returns a specific, actionable error code rather than
     * a generic failure — the single most common real case is exactly what
     * the brief flagged: www-data/php-fpm cannot read /var/log/nginx/* by
     * default on a stock nginx+PHP-FPM box, and the admin UI turns
     * 'not_readable' into a concrete "fix your file permissions" message.
     *
     * @return array{ok:bool,path:?string,error:?string}
     */
    public static function resolve_log_path( $configured_path ) {
        $configured_path = trim( (string) $configured_path );
        if ( '' === $configured_path ) {
            return array( 'ok' => false, 'path' => null, 'error' => 'empty' );
        }
        if ( false !== strpos( $configured_path, '..' ) ) {
            return array( 'ok' => false, 'path' => null, 'error' => 'traversal' );
        }
        $real = realpath( $configured_path );
        if ( false === $real || ! is_file( $real ) ) {
            return array( 'ok' => false, 'path' => null, 'error' => 'not_found' );
        }
        if ( ! is_readable( $real ) ) {
            return array( 'ok' => false, 'path' => null, 'error' => 'not_readable' );
        }
        return array( 'ok' => true, 'path' => $real, 'error' => null );
    }

    /** Private subdirectory (inside wp_upload_dir()) that uploaded log files are stored under. */
    const UPLOAD_SUBDIR = 'seoforge-verify';

    /**
     * The private upload directory's absolute path, creating it (plus an
     * index.php + .htaccess deny-all stub, same convention as other plugins'
     * private upload dirs) on first use. Core does not guarantee a custom
     * uploads subdirectory is unreachable over HTTP, so this is
     * defence-in-depth, not the only protection — the admin UI additionally
     * tells nginx-site owners to block this path at the webserver config,
     * since nginx config cannot be enforced from PHP.
     */
    public static function private_upload_dir() {
        $upload = wp_upload_dir();
        $dir    = trailingslashit( $upload['basedir'] ) . self::UPLOAD_SUBDIR;
        if ( ! is_dir( $dir ) ) {
            wp_mkdir_p( $dir );
        }
        $index = $dir . '/index.php';
        if ( is_dir( $dir ) && ! file_exists( $index ) ) {
            file_put_contents( $index, "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
        }
        $htaccess = $dir . '/.htaccess';
        if ( is_dir( $dir ) && ! file_exists( $htaccess ) ) {
            file_put_contents( $htaccess, "Require all denied\nDeny from all\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions
        }
        return $dir;
    }

    /**
     * Resolve + validate an uploaded log file's stored path — the fallback
     * for admins whose PHP-FPM user can't read the real nginx log directory.
     * Unlike resolve_log_path() above, this one DOES constrain the result
     * to inside $upload_dir: the filename ultimately comes from a browser
     * upload, and even though wp_handle_upload()/sanitize_file_name()
     * already neutralize traversal in the filename itself, this is the
     * defense-in-depth check that makes the constraint explicit and
     * independently verifiable rather than relying solely on upstream
     * sanitization. '..' rejected before realpath() runs; the realpath must
     * land inside $upload_dir's realpath.
     *
     * @return array{ok:bool,path:?string,error:?string}
     */
    public static function resolve_uploaded_path( $filename, $upload_dir ) {
        $filename = trim( (string) $filename );
        if ( '' === $filename || false !== strpos( $filename, '..' ) || false !== strpos( $filename, '/' ) || false !== strpos( $filename, '\\' ) ) {
            return array( 'ok' => false, 'path' => null, 'error' => 'invalid_name' );
        }
        $real_base = realpath( $upload_dir );
        if ( false === $real_base ) {
            return array( 'ok' => false, 'path' => null, 'error' => 'no_upload_dir' );
        }
        $candidate = rtrim( $real_base, '/\\' ) . DIRECTORY_SEPARATOR . $filename;
        $real = realpath( $candidate );
        if ( false === $real || ! is_file( $real ) ) {
            return array( 'ok' => false, 'path' => null, 'error' => 'not_found' );
        }
        if ( 0 !== strpos( $real, rtrim( $real_base, '/\\' ) . DIRECTORY_SEPARATOR ) ) {
            return array( 'ok' => false, 'path' => null, 'error' => 'outside_allowed' );
        }
        if ( ! is_readable( $real ) ) {
            return array( 'ok' => false, 'path' => null, 'error' => 'not_readable' );
        }
        return array( 'ok' => true, 'path' => $real, 'error' => null );
    }
}
