<?php
/**
 * Tamper-evident hash chain over wp_seoforge_history (1.9.1, DB v7).
 *
 * Each row's `row_hash` = sha256(prev_hash . '|' . canonical_json(row_content)),
 * chained per blog_id, ordered by captured_on. `prev_hash` of the first chained
 * row for a blog is genesis_hash($blog_id), not zeros — binding the chain to
 * that specific blog so no chain can be spliced onto another site's history.
 *
 * Rows written before this feature shipped (or before a blog's first capture
 * after upgrading) keep row_hash = NULL forever. That is intentional: this
 * class never fabricates a hash for history it did not itself compute at
 * capture time — a NULL row_hash plainly means "predates the chain", and the
 * admin UI must say so rather than drawing an unbroken-looking chain back
 * through data that was never actually verified.
 *
 * Once a row has a row_hash it is never silently recomputed. If
 * Seoforge_History::capture_day() is later called again for an already-
 * chained day (not a normal cron path — capture_yesterday() only ever
 * touches a day once; this can only happen via an explicit manual backfill)
 * and the content changes, the stored row_hash no longer matches the row's
 * current content, and verify() will correctly report that day as broken.
 * That is the intended signal, not a bug to route around.
 *
 * Deferred (explicitly out of scope for 1.9.1): pushing a blog's chain head
 * to the intel hub for network-checkpointing. verify()'s return shape already
 * carries `chain_head` for exactly that future use — see the class docblock
 * note in Seoforge_Verify.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_History_Chain {

    /** Columns that are never part of the hashed content — either row bookkeeping or the hash fields themselves. */
    private static $volatile_columns = array( 'id', 'prev_hash', 'row_hash', 'updated_at' );

    /** JSON-blob columns whose *nested* key order also needs normalizing, not just the top level. */
    private static $json_columns = array( 'score_dist', 'check_fail_pct', 'hours_profile', 'weekdays_profile', 'ai_crawl_counts' );

    /**
     * Integer columns, cast with (int) before hashing. Needed because
     * build_row() hands apply_chain() natively-typed PHP values (int/float),
     * but every later read of the same row goes through $wpdb, which returns
     * plain strings for every column regardless of the SQL column type —
     * without this normalization, canonicalize() on the pre-insert row and
     * canonicalize() on the same row re-fetched from the DB produce different
     * JSON (0 vs "0"), so verify() would report every freshly-chained row as
     * tampered the moment it round-trips through MySQL, 100% of the time.
     */
    private static $int_columns = array( 'blog_id', 'n_posts', 'pageviews', 'sessions', 'ai_referrals', 'ai_crawl_to_referral' );

    /**
     * decimal(5,2) columns, formatted to a fixed 2-decimal string before
     * hashing — MySQL always returns these as "0.00"-style strings, so the
     * pre-insert PHP float (0, 0.0, 12.5, ...) must be normalized to the same
     * fixed-precision string representation. Same round-trip issue as
     * $int_columns above, different failure mode (float vs "X.XX" string).
     */
    private static $decimal_columns = array( 'bounce_pct', 'engaged_pct' );

    /**
     * Per-blog genesis anchor. Deterministic, no secret involved (this isn't
     * an authentication token — it just seeds the chain so "first row" has a
     * defined prev_hash instead of an arbitrary sentinel like all-zeros,
     * and so the anchor itself is blog-specific).
     */
    public static function genesis_hash( $blog_id ) {
        return hash( 'sha256', 'seoforge-history-chain-genesis:' . (int) $blog_id );
    }

    /**
     * Deterministic canonical JSON for one history row's content: sorted
     * keys (including inside decoded JSON-blob columns), volatile columns
     * stripped, no wall-clock/auto-increment fields. Two calls with
     * logically-identical content always produce byte-identical output —
     * this is the property both the hash chain and the determinism check
     * (Seoforge_Verify::check_determinism()) depend on.
     */
    public static function canonicalize( array $row ) {
        $data = array();
        foreach ( $row as $key => $value ) {
            if ( in_array( $key, self::$volatile_columns, true ) ) {
                continue;
            }
            $data[ $key ] = self::normalize_value( $key, $value );
        }
        ksort( $data );
        return wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
    }

    private static function normalize_value( $key, $value ) {
        if ( in_array( $key, self::$int_columns, true ) ) {
            return (int) $value;
        }
        if ( in_array( $key, self::$decimal_columns, true ) ) {
            return number_format( (float) $value, 2, '.', '' );
        }
        if ( in_array( $key, self::$json_columns, true ) && is_string( $value ) ) {
            $decoded = json_decode( $value, true );
            if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) {
                return self::normalize_array( $decoded );
            }
            return $value; // malformed/empty JSON string — hash it verbatim, don't guess.
        }
        if ( is_array( $value ) ) {
            return self::normalize_array( $value );
        }
        return $value;
    }

    private static function normalize_array( array $arr ) {
        $out = array();
        foreach ( $arr as $k => $v ) {
            $out[ $k ] = is_array( $v ) ? self::normalize_array( $v ) : $v;
        }
        // Only sort associative arrays — sorting a JSON list (hours_profile:
        // 24 sequential entries) by key would just re-confirm 0..23 order,
        // harmless, but ksort() on a list is a no-op anyway so this is safe
        // either way; kept explicit for clarity.
        ksort( $out );
        return $out;
    }

    /** Pure: sha256(prev_hash . '|' . canonical content of $row). */
    public static function hash_row( $prev_hash, array $row ) {
        return hash( 'sha256', $prev_hash . '|' . self::canonicalize( $row ) );
    }

    /**
     * Chain one row into the sequence for $blog_id as of $date: find the
     * nearest earlier row for this blog that already carries a row_hash and
     * chain off it, or — if there isn't one (first-ever row, or every
     * earlier row predates the chain) — anchor to genesis_hash(). Returns
     * $row with prev_hash/row_hash set; does not touch the database itself
     * (Seoforge_History::capture_day() performs the actual insert/update).
     */
    public static function apply_chain( $blog_id, $date, array $row ) {
        $prev_hash        = self::find_prev_hash( $blog_id, $date );
        $row['prev_hash'] = $prev_hash;
        $row['row_hash']  = self::hash_row( $prev_hash, $row );
        return $row;
    }

    /**
     * Earliest captured_on date for $blog_id that already carries a row_hash
     * — i.e. "covered by the integrity chain since Y-m-d". Null when the
     * blog has no chained rows at all yet (fresh install, or upgraded but
     * hasn't captured a day since). The admin UI uses this to state plainly
     * which rows predate the chain rather than implying full coverage.
     */
    public static function covered_since( $blog_id ) {
        global $wpdb;
        $since = $wpdb->get_var( $wpdb->prepare(
            "SELECT MIN(captured_on) FROM %i WHERE blog_id = %d AND row_hash IS NOT NULL AND row_hash != ''",
            Seoforge_Utils::table( 'history' ), (int) $blog_id
        ) );
        return $since ? (string) $since : null;
    }

    private static function find_prev_hash( $blog_id, $date ) {
        global $wpdb;
        $table = Seoforge_Utils::table( 'history' );
        $prev  = $wpdb->get_var( $wpdb->prepare(
            "SELECT row_hash FROM %i WHERE blog_id = %d AND captured_on < %s AND row_hash IS NOT NULL AND row_hash != '' ORDER BY captured_on DESC LIMIT 1",
            $table, (int) $blog_id, $date
        ) );
        return $prev ? (string) $prev : self::genesis_hash( $blog_id );
    }

    /**
     * Verify the chain for one blog. Fetches the full row set up to $to
     * (defaults to today) — cheap even at max retention, ~1100 rows/blog —
     * and walks it once. $from only affects which break is reported as
     * "in range": a break earlier than $from is still surfaced (the
     * requested window can't honestly be called clean if an earlier link in
     * the same chain is broken), just flagged as before the requested start.
     *
     * @return array{ok:bool, break:?array, checked:int, covered_from:?string, covered_to:?string, chain_head:?string}
     */
    public static function verify( $blog_id, $from = null, $to = null ) {
        global $wpdb;
        $table = Seoforge_Utils::table( 'history' );
        $to    = $to ? (string) $to : gmdate( 'Y-m-d' );
        $rows  = (array) $wpdb->get_results( $wpdb->prepare(
            "SELECT * FROM %i WHERE blog_id = %d AND captured_on <= %s ORDER BY captured_on ASC",
            $table, (int) $blog_id, $to
        ), ARRAY_A );
        return self::build_verify( $rows, $from );
    }

    /** Pure: rows (oldest-first, any mix of chained/unchained) -> verify result. */
    public static function build_verify( array $rows, $from = null ) {
        $expected_prev = null; // null until the first chained row is seen
        $checked       = 0;
        $covered_from  = null;
        $covered_to    = null;
        $chain_head    = null;
        $break         = null;

        foreach ( $rows as $row ) {
            $hash = isset( $row['row_hash'] ) ? (string) $row['row_hash'] : '';
            if ( '' === $hash ) {
                continue; // predates the chain — not an error, just uncovered.
            }
            $prev_hash = isset( $row['prev_hash'] ) ? (string) $row['prev_hash'] : '';
            $date      = isset( $row['captured_on'] ) ? (string) $row['captured_on'] : '';

            $expected_link = ( null === $expected_prev )
                ? $prev_hash // first chained row we've seen — its own prev_hash is the anchor, nothing to compare against yet.
                : $expected_prev;

            $checked++;
            if ( null === $covered_from ) {
                $covered_from = $date;
            }
            $covered_to = $date;
            $chain_head = $hash;

            if ( null !== $expected_prev && $prev_hash !== $expected_link ) {
                $break = array(
                    'date'     => $date,
                    'type'     => 'link',
                    'expected' => $expected_link,
                    'actual'   => $prev_hash,
                );
                break;
            }

            $recomputed = self::hash_row( $prev_hash, $row );
            if ( $recomputed !== $hash ) {
                $break = array(
                    'date'     => $date,
                    'type'     => 'content',
                    'expected' => $recomputed,
                    'actual'   => $hash,
                );
                break;
            }

            $expected_prev = $hash;
        }

        if ( $break ) {
            $break['in_range'] = ( null === $from || $break['date'] >= (string) $from );
            return array(
                'ok'            => false,
                'break'         => $break,
                'checked'       => $checked,
                'covered_from'  => $covered_from,
                'covered_to'    => $covered_to,
                'chain_head'    => $chain_head,
            );
        }

        return array(
            'ok'           => true,
            'break'        => null,
            'checked'      => $checked,
            'covered_from' => $covered_from,
            'covered_to'   => $covered_to,
            'chain_head'   => $chain_head,
        );
    }
}
