<?php
/**
 * DataCodec — Safe DB Round-Trip Encoder for UltraGuard Pro
 *
 * WordPress sanitization functions (sanitize_text_field, wp_strip_all_tags,
 * sanitize_textarea_field) strip HTML-like tokens, null bytes, and perform
 * character conversion. This destroys:
 *
 *   • Regex patterns that match HTML   e.g. /<script[\s>]/i
 *   • JSON blobs whose values contain  HTML-like substrings
 *   • Notification templates with      emoji, angle brackets, or {placeholders}
 *     that look like tags
 *
 * DataCodec wraps such values in a Base64 envelope before they enter the
 * DatabaseCrud pipeline and unwraps them transparently after every read.
 *
 * WHY BASE64?
 *   Base64 output is pure ASCII: A-Z a-z 0-9 + / =
 *   None of these characters are touched by any WP sanitizer, so the encoded
 *   string round-trips through sanitize_text_field(), wp_strip_all_tags(),
 *   esc_sql(), and $wpdb->prepare() completely unchanged.
 *
 * ENVELOPE FORMAT
 *   Stored value  = UGC1:<base64url-safe payload>
 *   The "UGC1:" prefix (5 chars) is the version sentinel. Bumping the version
 *   digit invalidates old encoded values so migrations can be triggered.
 *
 * USAGE IN CHILD TABLES
 *   1. Override get_codec_fields() to list columns that need encoding.
 *   2. Override get_json_codec_fields() for columns that store JSON arrays/objects
 *      and should be automatically decoded back to PHP arrays on read.
 *
 * @package    UltraguardPro\core
 * @author     Ultraguard Pro Team
 * @since      1.3.0
 */

namespace UltraguardPro\core;

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

class DataCodec {

    // ─── Constants ────────────────────────────────────────────────────────────

    /**
     * Envelope prefix — MUST NOT contain characters altered by WP sanitizers.
     * Only uppercase ASCII letters and a colon are used intentionally.
     * Bump the digit (UGC2:, UGC3:...) when the payload format changes.
     */
    const PREFIX = 'UGC1:';

    /** Length of PREFIX — cached for efficiency. */
    const PREFIX_LEN = 5;

    // ─── Core encode / decode ─────────────────────────────────────────────────

    /**
     * Encode any scalar or array value into a DB-safe string.
     *
     * Arrays and objects are JSON-encoded first so that nested structures
     * round-trip correctly. NULL is returned unchanged (stored as SQL NULL).
     *
     * The resulting string consists solely of ASCII characters that survive
     * every WordPress sanitization function unchanged.
     *
     * @param  mixed  $value  Value to encode. NULL is returned as-is.
     * @return string|null    Encoded string, or NULL if $value was NULL.
     */
    public static function encode( $value ): ?string {
        if ( $value === null ) {
            return null;
        }

        // Arrays/objects → JSON first so the payload is a single string.
        if ( is_array( $value ) || is_object( $value ) ) {
            $original = $value; // Preserve before wp_json_encode overwrites $value on failure.
            $value    = wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
            if ( $value === false ) {
                // Fallback: serialize if json_encode fails (e.g. binary keys).
                $value = serialize( $original ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
            }
        } else {
            $value = (string) $value;
        }

        return self::PREFIX . base64_encode( $value ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
    }

    /**
     * Decode a value that was previously encoded by encode().
     *
     * Returns the value unchanged if it does not carry the UGC1: prefix
     * (e.g. legacy rows inserted before this codec was introduced, or plain
     * scalar values that never needed encoding).
     *
     * @param  mixed $value     Raw value from the database.
     * @param  bool  $as_array  When true and the payload is valid JSON, the
     *                          decoded JSON is returned as a PHP array instead
     *                          of a string. Use for JSON-blob columns.
     * @return mixed            Original value, or decoded payload.
     */
    public static function decode( $value, bool $as_array = false ) {
        if ( ! is_string( $value ) || ! self::is_encoded( $value ) ) {
            // Not encoded by us — return unchanged so legacy rows still work.
            return $value;
        }

        $b64     = substr( $value, self::PREFIX_LEN );
        $decoded = base64_decode( $b64, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode

        if ( $decoded === false ) {
            // Corrupted payload — return raw so callers can detect the issue.
            return $value;
        }

        if ( $as_array ) {
            $arr = json_decode( $decoded, true );
            return ( $arr !== null ) ? $arr : $decoded;
        }

        return $decoded;
    }

    // ─── Detection ───────────────────────────────────────────────────────────

    /**
     * Check whether a value was encoded by this class.
     *
     * @param  mixed $value
     * @return bool
     */
    public static function is_encoded( $value ): bool {
        return is_string( $value ) && strncmp( $value, self::PREFIX, self::PREFIX_LEN ) === 0;
    }

    // ─── Safe wrappers (idempotent) ───────────────────────────────────────────

    /**
     * Encode a value only if it is not already encoded.
     *
     * Prevents double-encoding when update() is called on a field that was
     * previously read and is being re-saved unchanged.
     *
     * @param  mixed  $value
     * @return string|null
     */
    public static function maybe_encode( $value ): ?string {
        if ( $value === null ) {
            return null;
        }

        $str = is_array( $value ) || is_object( $value )
            ? (string) wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES )
            : (string) $value;

        // Do not double-encode.
        if ( self::is_encoded( $str ) ) {
            return $str;
        }

        return self::encode( $value );
    }

    /**
     * Decode a value only if it carries the UGC1: prefix.
     * Safe to call on any value — plain strings pass through unchanged.
     *
     * @param  mixed $value
     * @param  bool  $as_array  Auto-decode JSON payload to a PHP array.
     * @return mixed
     */
    public static function maybe_decode( $value, bool $as_array = false ) {
        if ( ! is_string( $value ) || ! self::is_encoded( $value ) ) {
            return $value;
        }
        return self::decode( $value, $as_array );
    }

    // ─── Batch helpers (operate on associative row arrays) ────────────────────

    /**
     * Encode specific fields in an associative data array.
     *
     * Only keys listed in $fields that actually exist in $data are processed.
     * Fields whose current value is NULL are left as NULL (SQL NULL preserved).
     * Already-encoded fields are NOT double-encoded.
     *
     * @param  array    $data    Associative array of column => value pairs.
     * @param  string[] $fields  List of column names to encode.
     * @return array             $data with the listed fields encoded.
     */
    public static function encode_fields( array $data, array $fields ): array {
        if ( empty( $fields ) ) {
            return $data;
        }

        foreach ( $fields as $field ) {
            if ( ! array_key_exists( $field, $data ) ) {
                continue; // Field not in this operation — skip.
            }

            $v = $data[ $field ];

            if ( $v === null ) {
                continue; // Preserve SQL NULL.
            }

            $data[ $field ] = self::maybe_encode( $v );
        }

        return $data;
    }

    /**
     * Decode specific fields in a single row (associative array).
     *
     * Fields in $json_fields are decoded back to PHP arrays when the payload
     * is valid JSON. All other codec fields are decoded to plain strings.
     *
     * Rows coming from legacy storage (no UGC1: prefix) are returned unchanged,
     * ensuring backwards compatibility with data written before the codec was
     * introduced.
     *
     * @param  array    $row          Associative row array from the DB.
     * @param  string[] $fields       Columns that may be codec-encoded.
     * @param  string[] $json_fields  Subset of $fields whose payloads should be
     *                                auto-decoded from JSON to PHP arrays.
     * @return array                  Row with listed fields decoded.
     */
    public static function decode_fields( array $row, array $fields, array $json_fields = [] ): array {
        if ( empty( $fields ) ) {
            return $row;
        }

        foreach ( $fields as $field ) {
            if ( ! array_key_exists( $field, $row ) ) {
                continue;
            }

            $v = $row[ $field ];

            if ( $v === null || $v === '' ) {
                continue; // NULL / empty preserved.
            }

            $as_array     = in_array( $field, $json_fields, true );
            $row[ $field ] = self::maybe_decode( $v, $as_array );
        }

        return $row;
    }

    /**
     * Decode codec fields across an array of rows.
     *
     * Convenience wrapper around decode_fields() for result sets.
     *
     * @param  array    $rows         Array of associative row arrays.
     * @param  string[] $fields       Columns that may be codec-encoded.
     * @param  string[] $json_fields  Subset decoded to PHP arrays.
     * @return array                  Rows with listed fields decoded.
     */
    public static function decode_rows( array $rows, array $fields, array $json_fields = [] ): array {
        if ( empty( $fields ) || empty( $rows ) ) {
            return $rows;
        }

        foreach ( $rows as &$row ) {
            if ( is_array( $row ) ) {
                $row = self::decode_fields( $row, $fields, $json_fields );
            }
        }
        unset( $row );

        return $rows;
    }

    // ─── Diagnostic / migration helpers ──────────────────────────────────────

    /**
     * Return the raw Base64 payload of an encoded value (no decoding).
     * Useful for debugging or migration scripts.
     *
     * @param  string $encoded_value  A string starting with UGC1:.
     * @return string|false           The raw base64 substring, or false.
     */
    public static function get_raw_payload( string $encoded_value ) {
        if ( ! self::is_encoded( $encoded_value ) ) {
            return false;
        }
        return substr( $encoded_value, self::PREFIX_LEN );
    }

    /**
     * Validate that an encoded value can be decoded without error.
     *
     * @param  string $value
     * @return bool
     */
    public static function is_valid_encoded( string $value ): bool {
        if ( ! self::is_encoded( $value ) ) {
            return false;
        }
        $decoded = base64_decode( substr( $value, self::PREFIX_LEN ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
        return $decoded !== false;
    }
}
