<?php
/**
 * The "Eigenvergleich" (self-comparison) history store.
 *
 * 1.8.0's benchmark network compares a site to *other* sites, which
 * k-anonymity correctly suppresses on a small network. The comparison that
 * works on day one, for every install, needs no network at all: "how do I
 * compare to myself last month?" This class captures one daily aggregate
 * snapshot per blog — the same shape of numbers Seoforge_Intel already
 * computes for the outbound benchmark push — and stores it locally, forever
 * (well, for `history_retention_days`), so a trend line is possible without
 * re-scanning `visits`.
 *
 * Crucially, this is NOT the outbound snapshot: Seoforge_Intel::build_snapshot()
 * suppresses everything below MIN_PAGEVIEWS for anonymity, because that data
 * leaves the site and could otherwise fingerprint a handful of visitors. This
 * data never leaves the site, so that suppression does not apply here — a
 * small site still gets its own history from day one. To keep the two paths
 * from drifting apart, capture_day() calls the exact same pure/shared builder
 * methods Seoforge_Intel uses (build_analyzer_group(), hours_pct(),
 * weekdays_pct(), fetch_ai_rows() + classify_ai_family_hits()), just without
 * the low-volume gate that build_snapshot() applies one level up.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_History {

    /** Daily capture cron — independent of the intel network opt-in; this works for opted-out sites too. */
    const CRON_HOOK = 'seoforge_history_capture';

    /**
     * Default retention in days (~3 years, ~1100 rows/site — see the table
     * comment in Seoforge_Activator::create_tables()). This is aggregate-only
     * data with no personal content (no visitor rows, no IPs, no UAs), so it
     * can be kept far longer than `visits` (365d default) or `sessions`
     * (730d default) — there is no privacy reason to delete a trend line,
     * only a storage-size one, and at ~1 row/day the table stays tiny even
     * over many years. Filterable via the `history_retention_days` setting.
     */
    const RETENTION_DEFAULT = 1095;

    public static function register( Seoforge_Loader $loader ) {
        $loader->add_action( self::CRON_HOOK, __CLASS__, 'capture_yesterday' );
        $loader->add_action( 'seoforge_cleanup_visits', __CLASS__, 'cleanup' );
    }

    /* ------------------------------------------------------------- capture */

    /** Cron entry point: capture the previous full UTC day for the current blog. */
    public static function capture_yesterday() {
        self::capture_day( gmdate( 'Y-m-d', time() - DAY_IN_SECONDS ), Seoforge_Utils::current_blog_id() );
    }

    /**
     * Build + upsert one (blog_id, captured_on) row. Idempotent — re-running
     * for the same day updates the existing row instead of duplicating it.
     *
     * @param string|null $date    'Y-m-d' UTC date to capture; defaults to yesterday.
     * @param int|null    $blog_id Defaults to the current blog.
     * @return int The row id.
     */
    public static function capture_day( $date = null, $blog_id = null ) {
        global $wpdb;
        $date    = $date ? (string) $date : gmdate( 'Y-m-d', time() - DAY_IN_SECONDS );
        $blog_id = null === $blog_id ? Seoforge_Utils::current_blog_id() : (int) $blog_id;

        $row = self::build_row( $date, $blog_id );

        $table       = Seoforge_Utils::table( 'history' );
        $existing    = $wpdb->get_row( $wpdb->prepare(
            "SELECT id, row_hash FROM %i WHERE blog_id = %d AND captured_on = %s LIMIT 1",
            $table, $blog_id, $date
        ), ARRAY_A );
        $existing_id = $existing ? (int) $existing['id'] : 0;

        // Chain a row exactly once: on first insert, or when an existing row
        // was never chained (predates 1.9.1 / this blog's first capture
        // after upgrading). An already-chained row's hash is deliberately
        // never recomputed here — see Seoforge_History_Chain's class docblock.
        if ( class_exists( 'Seoforge_History_Chain' )
                && ( ! $existing_id || empty( $existing['row_hash'] ) ) ) {
            $row = Seoforge_History_Chain::apply_chain( $blog_id, $date, $row );
        }

        if ( $existing_id ) {
            $wpdb->update( $table, $row, array( 'id' => $existing_id ) );
            return $existing_id;
        }
        $wpdb->insert( $table, $row );
        return (int) $wpdb->insert_id;
    }

    /**
     * Assembles the row for one blog/day, reusing Seoforge_Intel's shared
     * builder methods directly (no low-volume suppression — see class docblock).
     *
     * Public (not just an implementation detail of capture_day()) so
     * Seoforge_Verify::check_determinism() can call it twice back-to-back
     * and assert the canonical content is byte-identical, without writing
     * anything to the database.
     */
    public static function build_row( $date, $blog_id ) {
        $start_ts  = strtotime( $date . ' 00:00:00 UTC' );
        $end_ts    = $start_ts + DAY_IN_SECONDS - 1;
        $start_sql = gmdate( 'Y-m-d H:i:s', $start_ts );
        $end_sql   = gmdate( 'Y-m-d H:i:s', $end_ts );

        $k = array();
        if ( class_exists( 'Seoforge_Reports' ) ) {
            $agg = Seoforge_Reports::aggregate( $start_ts, $end_ts );
            $k   = isset( $agg['kpis'] ) ? $agg['kpis'] : array();
        }

        $analyzer = class_exists( 'Seoforge_Intel' ) ? Seoforge_Intel::build_analyzer_group( $blog_id ) : null;

        $hours    = array_fill( 0, 24, 0 );
        $weekdays = array_fill( 0, 7, 0 );
        if ( class_exists( 'Seoforge_Intel' ) && class_exists( 'Seoforge_Stats' ) ) {
            $hours    = Seoforge_Intel::hours_pct( Seoforge_Stats::hour_profile( $start_sql, $end_sql, $blog_id ) );
            $weekdays = Seoforge_Intel::weekdays_pct( Seoforge_Stats::weekday_profile( 28, $blog_id ) );
        }

        $ai_family_counts    = array();
        $ai_referrals        = 0;
        $ai_crawl_to_referral = -1;
        if ( class_exists( 'Seoforge_Intel' ) ) {
            list( $ua_rows, $ai_referrals ) = Seoforge_Intel::fetch_ai_rows( $blog_id, $start_sql, $end_sql );
            $ai_family_counts     = array_filter( Seoforge_Intel::classify_ai_family_hits( $ua_rows ) );
            $ai_crawl_to_referral = Seoforge_Intel::crawl_to_referral( array_sum( $ai_family_counts ), $ai_referrals );
        }

        return array(
            'blog_id'              => $blog_id,
            'captured_on'          => $date,
            'n_posts'              => $analyzer ? (int) $analyzer['n_posts'] : 0,
            'score_dist'           => wp_json_encode( $analyzer ? $analyzer['score_dist'] : array() ),
            'check_fail_pct'       => wp_json_encode( $analyzer ? $analyzer['check_fail_pct'] : array() ),
            'hours_profile'        => wp_json_encode( $hours ),
            'weekdays_profile'     => wp_json_encode( $weekdays ),
            'pageviews'            => isset( $k['views'] ) ? (int) $k['views'] : 0,
            'sessions'             => isset( $k['sessions'] ) ? (int) $k['sessions'] : 0,
            'bounce_pct'           => isset( $k['bounce_pct'] ) ? (float) $k['bounce_pct'] : 0,
            'engaged_pct'          => isset( $k['engaged_pct'] ) ? (float) $k['engaged_pct'] : 0,
            'ai_crawl_counts'      => wp_json_encode( $ai_family_counts ),
            'ai_referrals'         => (int) $ai_referrals,
            'ai_crawl_to_referral' => (int) $ai_crawl_to_referral,
            'updated_at'           => current_time( 'mysql', 1 ),
        );
    }

    /* ------------------------------------------------------------- cleanup */

    /**
     * Retention cleanup, scoped to one blog (defaults to the current one) so
     * a Multisite cleanup never touches another site's rows — same contract
     * as every other *::cleanup() (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( 'history_retention_days', self::RETENTION_DEFAULT ) );
        $cutoff  = gmdate( 'Y-m-d', time() - DAY_IN_SECONDS * $days );
        return $wpdb->query( $wpdb->prepare(
            "DELETE FROM %i WHERE blog_id = %d AND captured_on < %s",
            Seoforge_Utils::table( 'history' ), $blog_id, $cutoff
        ) );
    }

    /* -------------------------------------------------------------- reads */

    /**
     * A metric series over the trailing $days days, oldest first. Each entry
     * is ['date' => 'Y-m-d', 'value' => mixed|null]; 'value' is null for a
     * day with no captured row for the metric requested, so a caller never
     * has to special-case a gap.
     *
     * $metric is a dotted path: a bare column name ('n_posts', 'pageviews',
     * 'bounce_pct', 'ai_referrals', 'ai_crawl_to_referral', ...) or
     * '<json_column>.<key>' into one of the JSON columns, e.g.
     * 'check_fail_pct.meta_description', 'score_dist.81_100',
     * 'ai_crawl_counts.GPTBot'.
     */
    public static function series( $metric, $days, $blog_id = null ) {
        $blog_id = null === $blog_id ? Seoforge_Utils::current_blog_id() : (int) $blog_id;
        $days    = max( 1, (int) $days );
        return self::build_series( self::fetch_rows( $blog_id, $days ), $metric );
    }

    /** Pure: rows (oldest-first) -> [{date,value}], value null on a missing metric. */
    public static function build_series( array $rows, $metric ) {
        $out = array();
        foreach ( $rows as $row ) {
            $out[] = array(
                'date'  => isset( $row['captured_on'] ) ? (string) $row['captured_on'] : '',
                'value' => self::metric_value( $row, $metric ),
            );
        }
        return $out;
    }

    /**
     * Current-vs-previous-period comparison: the average of $metric over the
     * trailing $window_days days vs. the $window_days days before that.
     * Returns ['current' => float|null, 'previous' => float|null,
     * 'delta' => float|null]. With no rows in a period the value is null —
     * never a divide-by-zero, never a fabricated 0.
     */
    public static function compare( $metric, $window_days, $blog_id = null ) {
        $blog_id     = null === $blog_id ? Seoforge_Utils::current_blog_id() : (int) $blog_id;
        $window_days = max( 1, (int) $window_days );

        $today       = gmdate( 'Y-m-d' );
        $cur_start   = gmdate( 'Y-m-d', strtotime( $today ) - DAY_IN_SECONDS * $window_days );
        $prev_start  = gmdate( 'Y-m-d', strtotime( $today ) - DAY_IN_SECONDS * $window_days * 2 );

        $current  = self::fetch_range( $blog_id, $cur_start, $today );
        $previous = self::fetch_range( $blog_id, $prev_start, $cur_start );

        return self::build_compare( $current, $previous, $metric );
    }

    /** Pure: two row sets -> {current,previous,delta} averages of $metric. */
    public static function build_compare( array $current_rows, array $previous_rows, $metric ) {
        $cur  = self::average_metric( $current_rows, $metric );
        $prev = self::average_metric( $previous_rows, $metric );
        return array(
            'current'  => $cur,
            'previous' => $prev,
            'delta'    => ( null !== $cur && null !== $prev ) ? $cur - $prev : null,
        );
    }

    /**
     * Pure: dotted-path metric lookup on one decoded row. Returns null when
     * the column, the JSON key, or the JSON itself is missing/malformed —
     * never a notice, never an implicit 0.
     */
    public static function metric_value( array $row, $metric ) {
        $parts = explode( '.', (string) $metric, 2 );
        $base  = $parts[0];
        $json_columns = array( 'score_dist', 'check_fail_pct', 'hours_profile', 'weekdays_profile', 'ai_crawl_counts' );

        if ( in_array( $base, $json_columns, true ) ) {
            $raw = isset( $row[ $base ] ) ? $row[ $base ] : null;
            if ( is_string( $raw ) ) {
                $decoded = json_decode( $raw, true );
            } elseif ( is_array( $raw ) ) {
                $decoded = $raw;
            } else {
                $decoded = null;
            }
            if ( ! is_array( $decoded ) ) {
                return null;
            }
            if ( ! isset( $parts[1] ) ) {
                return $decoded;
            }
            return array_key_exists( $parts[1], $decoded ) ? $decoded[ $parts[1] ] : null;
        }

        return array_key_exists( $base, $row ) ? $row[ $base ] : null;
    }

    private static function average_metric( array $rows, $metric ) {
        $values = array();
        foreach ( $rows as $row ) {
            $v = self::metric_value( $row, $metric );
            if ( is_int( $v ) || is_float( $v ) || ( is_string( $v ) && is_numeric( $v ) ) ) {
                $values[] = (float) $v;
            }
        }
        if ( empty( $values ) ) {
            return null;
        }
        return array_sum( $values ) / count( $values );
    }

    /**
     * How many days actually have a captured row in the current and previous
     * `compare()` windows — lets a view tell "no history yet" apart from
     * "partial window, still filling" apart from "full window" without
     * guessing from the (possibly zero-padded) averages alone.
     *
     * @return array{current:int, previous:int}
     */
    public static function capture_counts( $window_days, $blog_id = null ) {
        $blog_id     = null === $blog_id ? Seoforge_Utils::current_blog_id() : (int) $blog_id;
        $window_days = max( 1, (int) $window_days );

        $today      = gmdate( 'Y-m-d' );
        $cur_start  = gmdate( 'Y-m-d', strtotime( $today ) - DAY_IN_SECONDS * $window_days );
        $prev_start = gmdate( 'Y-m-d', strtotime( $today ) - DAY_IN_SECONDS * $window_days * 2 );

        return array(
            'current'  => count( self::fetch_range( $blog_id, $cur_start, $today ) ),
            'previous' => count( self::fetch_range( $blog_id, $prev_start, $cur_start ) ),
        );
    }

    /* --------------------------------------------------------- data fetch */

    /** Trailing N days of rows for one blog, oldest first. */
    private static function fetch_rows( $blog_id, $days ) {
        global $wpdb;
        $since = gmdate( 'Y-m-d', time() - DAY_IN_SECONDS * $days );
        return (array) $wpdb->get_results( $wpdb->prepare(
            "SELECT * FROM %i WHERE blog_id = %d AND captured_on >= %s ORDER BY captured_on ASC",
            Seoforge_Utils::table( 'history' ), (int) $blog_id, $since
        ), ARRAY_A );
    }

    /** Rows for one blog in [$start, $end) — half-open so adjacent periods never overlap. */
    private static function fetch_range( $blog_id, $start, $end ) {
        global $wpdb;
        return (array) $wpdb->get_results( $wpdb->prepare(
            "SELECT * FROM %i WHERE blog_id = %d AND captured_on >= %s AND captured_on < %s ORDER BY captured_on ASC",
            Seoforge_Utils::table( 'history' ), (int) $blog_id, $start, $end
        ), ARRAY_A );
    }
}
