<?php
defined( 'ABSPATH' ) || exit;

/**
 * Quota management for Pipeline Buster.
 *
 * Free tier: PIPEBU_Quota::FREE_LIMIT products lifetime.
 * Dev mode:  Bypasses all checks — set once on trusted installs (VPSS, test envs).
 *
 * Option names are intentionally non-descriptive.
 * Integrity: counter + HMAC stored in parallel. Direct DB edits invalidate the hash
 * and are treated as quota exceeded, not as a reset to zero.
 *
 * Abuse prevention:
 *   High water mark (pb_s4 + pb_s4h) tracks the maximum count ever reached.
 *   Deleting the primary counter (pb_s1/pb_s1h) does not reset the HWM.
 *   If HWM >= FREE_LIMIT, quota is exceeded regardless of the current counter state.
 *
 * Freemius hook: pb_has_paid_license filter — returns true once SDK is wired.
 * Until then, returns false and free-tier rules apply.
 */
class PIPEBU_Quota {

    const FREE_LIMIT = 5;

    // Obfuscated option keys — non-obvious names resist casual DB inspection.
    private const OPT_COUNT   = 'pipebu_s1';    // int: products published this install
    private const OPT_HASH    = 'pipebu_s1h';   // string: HMAC of count — tamper detection
    private const OPT_DEV     = 'pipebu_s2';    // bool: dev/unlimited mode flag
    private const OPT_KEY     = 'pipebu_s5';    // string: stable signing key (generated once, AUTH_KEY-independent)
    private const OPT_HWM     = 'pipebu_s4';    // int: high water mark (max count ever reached)
    private const OPT_HWM_H   = 'pipebu_s4h';  // string: HMAC of HWM — tamper detection
    private const OPT_SEC_LOG = 'pipebu_seclog'; // array: local tamper event log
    private const SEC_LOG_MAX  = 100;        // keep last 100 events

    // Credit system — paid tier.
    private const OPT_CREDITS       = 'pipebu_cr';   // int: purchased credits remaining
    private const OPT_CREDITS_H     = 'pipebu_crh';  // string: HMAC of balance
    private const OPT_CREDITS_TOT   = 'pipebu_crt';  // int: total credits ever purchased (only rises)
    private const OPT_CREDITS_TOT_H = 'pipebu_crth'; // string: HMAC of total purchased
    private const OPT_LICENSE_LOG   = 'pipebu_crl';  // JSON: license keys already credited (dedup)
    private const LICENSE_LOG_MAX   = 200;        // keep last 200 license keys

    // Plan tracking + wall offer system.
    private const OPT_PLAN_ID      = 'pipebu_pid';  // int: Freemius plan ID of last purchase
    private const OPT_WALL_FIRST   = 'pipebu_wf';   // int: Unix timestamp of first wall hit
    private const OPT_WALL_FIRST_H = 'pipebu_wfh';  // string: HMAC of wall timestamp
    private const OPT_SALE         = 'pipebu_sale';  // bool: sale active flag (admin-set)
    private const WALL_OFFER_DAYS  = 3;          // personal offer window in days

    // Freemius plan ID → credit quantity map.
    private const PLAN_CREDITS = [
        45802 => 1,   // Single
        46842 => 5,   // 5 Products
        46846 => 10,  // 10 Products
        46847 => 25,  // 25 Products
    ];

    // Current plan → next tier upsell map. 0 = free tier entry point.
    private const PLAN_NEXT_TIER = [
        0     => [ 'plan_id' => 45802, 'name' => 'Single Credit', 'credits' => 1,  'price' => 100,  'savings' => 0   ],
        45802 => [ 'plan_id' => 46842, 'name' => '5-Pack',        'credits' => 5,  'price' => 400,  'savings' => 100 ],
        46842 => [ 'plan_id' => 46846, 'name' => '10-Pack',       'credits' => 10, 'price' => 750,  'savings' => 250 ],
        46846 => [ 'plan_id' => 46847, 'name' => '25-Pack',       'credits' => 25, 'price' => 1750, 'savings' => 750 ],
        46847 => null, // Enterprise — contact us.
    ];

    private const CHECKOUT_BASE = 'https://checkout.freemius.com/plugin/27716/plan/';

    // Server endpoints — pipelinebuster.com handles all quota enforcement and usage telemetry.
    private const QUOTA_URL     = 'https://www.pipelinebuster.com/api/quota.php';
    private const TELEMETRY_URL = 'https://www.pipelinebuster.com/api/usage.php';

    // Grace cache — last known server response, used during server downtime (24hr window).
    private const OPT_GRACE   = 'pipebu_grc';   // JSON: cached server quota response
    private const OPT_GRACE_H = 'pipebu_grch';  // HMAC of grace cache — tamper detection
    private const GRACE_TTL   = 86400;           // seconds: 24hr offline window

    // -------------------------------------------------------------------------
    // Public API
    // -------------------------------------------------------------------------

    /**
     * Whether this install can run a job.
     *
     * All features (Guide Mode, Custom Tone) are available on all tiers.
     * The service quota is the only gate — free installs get FREE_LIMIT runs.
     *
     * Error codes returned via WP JSON error (not exceptions):
     *   quota_exceeded    — free product limit hit
     *   credits_exhausted — paid credits depleted
     *
     * @param bool $guide_mode  True when running How To Only path (unused for gating).
     * @return true|string  true if allowed, error code string if blocked.
     */
    public static function can_publish( bool $guide_mode = false ) {
        if ( self::is_dev_mode() ) {
            return true;
        }
        $has_paid = (bool) apply_filters( 'pipebu_has_paid_license', false );
        if ( $has_paid ) {
            if ( ! self::credit_integrity_ok() ) {
                return 'quota_exceeded';
            }
            if ( ! self::has_credits() ) {
                return 'credits_exhausted';
            }
            return true;
        }
        // Free tier — server-enforced quota. No local gate (WP.org Guideline 5 compliant).
        return self::request_quota_token();
    }

    /**
     * Increment the counter after a successful publish (product OR Guide Mode).
     * Both modes count as 1 credit — v2 doctrine, locked Apr 8, 2026.
     *
     * @param array $features Optional feature-adoption flags for telemetry (see ping_home()).
     */
    public static function increment( array $features = [] ): void {
        if ( self::is_dev_mode() ) {
            return;
        }
        // Lifetime generation count is tracked locally for BOTH tiers now — previously only
        // the free branch bumped OPT_COUNT, so get_used() (read by the Reports page "Products
        // Published" card) silently stayed 0 forever for paid installs regardless of real usage.
        $count = self::get_used() + 1;
        self::update_opt( self::OPT_COUNT, $count );
        self::update_opt( self::OPT_HASH,  self::make_hash( $count ) );
        $hwm = self::get_hwm();
        if ( $count > $hwm ) {
            self::update_opt( self::OPT_HWM,   $count );
            self::update_opt( self::OPT_HWM_H, self::make_hwm_hash( $count ) );
        }
        if ( apply_filters( 'pipebu_has_paid_license', false ) ) {
            self::deduct_credit();
            self::ping_home( $count, $features, true, self::get_credit_balance() );
            return;
        }
        self::ping_home( $count, $features );
    }

    /** Products published on this install (or network). */
    public static function get_used(): int {
        return (int) self::get_opt( self::OPT_COUNT, 0 );
    }

    /** Credits remaining: credit balance for paid installs, server-reported count for free installs. */
    public static function get_remaining(): int {
        if ( self::is_dev_mode() ) {
            return PHP_INT_MAX;
        }
        if ( apply_filters( 'pipebu_has_paid_license', false ) ) {
            return self::get_credit_balance();
        }
        // Use grace cache (reflects last server response) when available and fresh.
        $raw = (string) self::get_opt( self::OPT_GRACE, '' );
        if ( $raw !== '' ) {
            $stored_h = (string) self::get_opt( self::OPT_GRACE_H, '' );
            if ( $stored_h !== '' && hash_equals( $stored_h, hash_hmac( 'sha256', $raw, self::signing_key() ) ) ) {
                $grace = json_decode( $raw, true );
                if ( is_array( $grace ) && (int) ( $grace['expires'] ?? 0 ) >= time() ) {
                    return (int) ( $grace['remaining'] ?? 0 );
                }
            }
        }
        return max( 0, self::FREE_LIMIT - self::get_used() );
    }

    // -------------------------------------------------------------------------
    // Credit system — public API
    // -------------------------------------------------------------------------

    /** Credits granted for a given Freemius plan ID. Returns 0 for unknown plans. */
    public static function credits_for_plan( int $plan_id ): int {
        return self::PLAN_CREDITS[ $plan_id ] ?? 0;
    }

    /** Persist the plan ID on license activation so the wall can tier-up correctly. */
    public static function set_plan_id( int $plan_id ): void {
        if ( $plan_id > 0 ) {
            self::update_opt( self::OPT_PLAN_ID, $plan_id );
        }
    }

    /** Plan ID of the most recent purchase on this install. 0 = free tier. */
    public static function get_plan_id(): int {
        return (int) self::get_opt( self::OPT_PLAN_ID, 0 );
    }

    /**
     * Record the first time this install hits a quota wall.
     * Idempotent — subsequent calls are no-ops. Starts the personal offer countdown.
     */
    public static function record_first_wall_hit(): void {
        if ( (int) self::get_opt( self::OPT_WALL_FIRST, 0 ) > 0 ) {
            return;
        }
        $ts = time();
        self::update_opt( self::OPT_WALL_FIRST,   $ts );
        self::update_opt( self::OPT_WALL_FIRST_H, self::make_wall_hash( $ts ) );
    }

    /**
     * Unix timestamp when the personal offer expires (first wall hit + WALL_OFFER_DAYS).
     * Returns 0 if no hit recorded or if the timestamp has been tampered with.
     */
    public static function get_wall_expiry(): int {
        $ts = (int) self::get_opt( self::OPT_WALL_FIRST, 0 );
        if ( $ts === 0 ) {
            return 0;
        }
        $stored = (string) self::get_opt( self::OPT_WALL_FIRST_H, '' );
        if ( $stored === '' || ! hash_equals( $stored, self::make_wall_hash( $ts ) ) ) {
            return 0;
        }
        return $ts + ( self::WALL_OFFER_DAYS * DAY_IN_SECONDS );
    }

    /** True when admin has flagged an active sale. Sale display takes priority over personal countdown. */
    public static function is_sale_active(): bool {
        return (bool) self::get_opt( self::OPT_SALE, false );
    }

    /**
     * Build the wall offer payload for AJAX responses.
     * Priority: sale active → personal countdown → standard.
     * Call record_first_wall_hit() before this so expiry is always set on first hit.
     */
    public static function get_wall_offer(): array {
        $plan_id = self::get_plan_id();
        $tier    = self::PLAN_NEXT_TIER[ $plan_id ] ?? null;

        $sale = self::is_sale_active();

        if ( $tier === null ) {
            return [
                'enterprise'   => true,
                'sale_active'  => $sale,
                'checkout_url' => 'https://pipelinebuster.com/#contact',
                'expiry_ts'    => 0,
            ];
        }

        return [
            'enterprise'   => false,
            'name'         => $tier['name'],
            'credits'      => $tier['credits'],
            'price'        => $tier['price'],
            'savings'      => $tier['savings'],
            'checkout_url' => self::CHECKOUT_BASE . $tier['plan_id'] . '/licenses/1/',
            'sale_active'  => $sale,
            'expiry_ts'    => $sale ? 0 : self::get_wall_expiry(),
        ];
    }

    /** Current purchased credit balance. */
    public static function get_credit_balance(): int {
        return (int) self::get_opt( self::OPT_CREDITS, 0 );
    }

    /** Total credits ever purchased on this install (never decreases). */
    public static function get_total_purchased(): int {
        return (int) self::get_opt( self::OPT_CREDITS_TOT, 0 );
    }

    /** True if at least 1 credit remains. */
    public static function has_credits(): bool {
        return self::get_credit_balance() > 0;
    }

    /**
     * Add N credits on license activation. Idempotent — safe to call on repeated activations.
     * $license_key is used to prevent double-granting the same purchase.
     */
    public static function add_credits( int $n, string $license_key ): void {
        if ( $n <= 0 || $license_key === '' ) {
            return;
        }
        // Idempotency check — skip if this license already credited.
        $log = json_decode( (string) self::get_opt( self::OPT_LICENSE_LOG, '[]' ), true );
        if ( ! is_array( $log ) ) {
            $log = [];
        }
        if ( in_array( $license_key, $log, true ) ) {
            return;
        }
        // Update credit balance.
        $balance = self::get_credit_balance() + $n;
        self::update_opt( self::OPT_CREDITS,   $balance );
        self::update_opt( self::OPT_CREDITS_H, self::make_credit_hash( $balance ) );
        // Update total purchased (monotonically increasing).
        $total = self::get_total_purchased() + $n;
        self::update_opt( self::OPT_CREDITS_TOT,   $total );
        self::update_opt( self::OPT_CREDITS_TOT_H, self::make_total_hash( $total ) );
        // Record license key.
        $log[] = $license_key;
        if ( count( $log ) > self::LICENSE_LOG_MAX ) {
            $log = array_slice( $log, - self::LICENSE_LOG_MAX );
        }
        self::update_opt( self::OPT_LICENSE_LOG, wp_json_encode( $log ) );
    }

    /** True if this install is in dev/unlimited mode. */
    public static function is_dev_mode(): bool {
        return (bool) self::get_opt( self::OPT_DEV, false );
    }

    /**
     * Enable or disable dev mode. Admin-only — call from trusted contexts.
     * Persists across requests. One-time setup per install.
     */
    public static function set_dev_mode( bool $enabled ): void {
        self::update_opt( self::OPT_DEV, $enabled );
    }

    // -------------------------------------------------------------------------
    // Option helpers — Multisite-aware
    // On Multisite networks, quota is stored at network level (wp_sitemeta)
    // so all subsites share one pool. Prevents N×FREE_LIMIT abuse.
    // -------------------------------------------------------------------------

    private static function get_opt( string $key, $default = false ) {
        return is_multisite()
            ? get_site_option( $key, $default )
            : get_option( $key, $default );
    }

    private static function update_opt( string $key, $value ): void {
        if ( is_multisite() ) {
            update_site_option( $key, $value );
        } else {
            update_option( $key, $value, false );
        }
    }

    /**
     * Stable signing key — generated once on first use, persists forever.
     * Using this instead of AUTH_KEY means WP security key rotation never
     * invalidates stored HMACs. Domain-locked via home_url() at call sites.
     */
    private static function signing_key(): string {
        $key = (string) self::get_opt( self::OPT_KEY, '' );
        if ( $key === '' ) {
            $key = wp_generate_password( 64, true, true );
            self::update_opt( self::OPT_KEY, $key );
        }
        return $key;
    }

    // -------------------------------------------------------------------------
    // High water mark
    // -------------------------------------------------------------------------

    /** Highest count ever recorded on this install (or network). */
    private static function get_hwm(): int {
        return (int) self::get_opt( self::OPT_HWM, 0 );
    }

    private static function make_hwm_hash( int $hwm ): string {
        return hash_hmac( 'sha256', 'hwm:' . $hwm, self::signing_key() . home_url() );
    }

    private static function hwm_integrity_ok(): bool {
        $hwm = self::get_hwm();
        if ( $hwm === 0 ) {
            return true; // No HWM yet — genuine fresh install.
        }
        $stored = (string) self::get_opt( self::OPT_HWM_H, '' );
        if ( $stored === '' ) {
            self::update_opt( self::OPT_HWM_H, self::make_hwm_hash( $hwm ) );
            return true;
        }
        if ( hash_equals( $stored, self::make_hwm_hash( $hwm ) ) ) {
            return true;
        }
        // Legacy migration: hash was signed with AUTH_KEY before pb_s5 existed.
        $legacy = hash_hmac( 'sha256', 'hwm:' . $hwm, AUTH_KEY . home_url() );
        if ( hash_equals( $stored, $legacy ) ) {
            self::update_opt( self::OPT_HWM_H, self::make_hwm_hash( $hwm ) );
            return true;
        }
        return false;
    }

    // -------------------------------------------------------------------------
    // Integrity check — HMAC tamper detection (primary counter)
    // -------------------------------------------------------------------------

    private static function make_hash( int $count ): string {
        return hash_hmac( 'sha256', (string) $count, self::signing_key() . home_url() );
    }

    private static function integrity_ok(): bool {
        $count = (int) self::get_opt( self::OPT_COUNT, 0 );
        if ( $count === 0 ) {
            return true; // Fresh install — no hash to verify yet.
        }
        $stored = (string) self::get_opt( self::OPT_HASH, '' );
        if ( $stored === '' ) {
            // Hash missing — seed it now (graceful migration for pre-quota installs).
            self::update_opt( self::OPT_HASH, self::make_hash( $count ) );
            return true;
        }
        if ( hash_equals( $stored, self::make_hash( $count ) ) ) {
            return true;
        }
        // Legacy migration: hash was signed with AUTH_KEY before pb_s5 existed.
        $legacy = hash_hmac( 'sha256', (string) $count, AUTH_KEY . home_url() );
        if ( hash_equals( $stored, $legacy ) ) {
            self::update_opt( self::OPT_HASH, self::make_hash( $count ) );
            return true;
        }
        return false;
    }

    // -------------------------------------------------------------------------
    // Credit integrity — HMAC tamper detection (paid tier)
    // -------------------------------------------------------------------------

    private static function make_credit_hash( int $balance ): string {
        return hash_hmac( 'sha256', 'cr:' . $balance, self::signing_key() . home_url() );
    }

    private static function make_total_hash( int $total ): string {
        return hash_hmac( 'sha256', 'crt:' . $total, self::signing_key() . home_url() );
    }

    private static function make_wall_hash( int $ts ): string {
        return hash_hmac( 'sha256', 'wf:' . $ts, self::signing_key() . home_url() );
    }

    /**
     * Both HMACs must verify AND balance must not exceed total purchased.
     * Balance > total = manual inflation in the DB. Treated same as tamper.
     */
    private static function credit_integrity_ok(): bool {
        $balance = self::get_credit_balance();
        $total   = self::get_total_purchased();

        $stored_cr = (string) self::get_opt( self::OPT_CREDITS_H, '' );
        if ( $stored_cr !== '' && ! hash_equals( $stored_cr, self::make_credit_hash( $balance ) ) ) {
            self::ping_tamper( 'credit_tamper' );
            return false;
        }

        $stored_crt = (string) self::get_opt( self::OPT_CREDITS_TOT_H, '' );
        if ( $stored_crt !== '' && ! hash_equals( $stored_crt, self::make_total_hash( $total ) ) ) {
            self::ping_tamper( 'credit_total_tamper' );
            return false;
        }

        // High water mark equivalent: balance can never exceed total purchased.
        if ( $total > 0 && $balance > $total ) {
            self::ping_tamper( 'credit_inflation' );
            return false;
        }

        return true;
    }

    private static function deduct_credit(): void {
        $balance = max( 0, self::get_credit_balance() - 1 );
        self::update_opt( self::OPT_CREDITS,   $balance );
        self::update_opt( self::OPT_CREDITS_H, self::make_credit_hash( $balance ) );
    }

    // -------------------------------------------------------------------------
    // Server quota — request token + grace fallback
    // -------------------------------------------------------------------------

    /**
     * Ask pipelinebuster.com whether this install may run a generation.
     * Blocking call (5s timeout) — server is the gate.
     * Falls back to grace cache if server is unreachable.
     *
     * @return true|string  true if allowed, error code string if blocked.
     */
    private static function request_quota_token() {
        $domain_hash = self::domain_hash();
        $timestamp   = time();
        $sig         = hash_hmac( 'sha256', $domain_hash . ':' . $timestamp, self::signing_key() );

        $response = wp_remote_post( self::QUOTA_URL, [
            'blocking'  => true,
            'timeout'   => 5,
            'sslverify' => true,
            'body'      => [
                'd' => $domain_hash,
                's' => $sig,
                't' => $timestamp,
                'v' => PIPEBU_VERSION,
            ],
        ] );

        if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
            return self::use_grace_token();
        }

        $body = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $body ) ) {
            return self::use_grace_token();
        }

        self::store_grace_token( $body );

        if ( empty( $body['allowed'] ) ) {
            return isset( $body['reason'] ) ? (string) $body['reason'] : 'quota_exceeded';
        }
        return true;
    }

    /**
     * Use the cached last server response when the server is unreachable.
     * Grants a 24hr offline window for installs that previously received approval.
     * Fails closed (quota_exceeded) if no valid grace cache exists.
     *
     * @return true|string
     */
    private static function use_grace_token() {
        $raw = (string) self::get_opt( self::OPT_GRACE, '' );
        if ( $raw === '' ) {
            return 'quota_exceeded';
        }
        $stored_h = (string) self::get_opt( self::OPT_GRACE_H, '' );
        if ( $stored_h === '' || ! hash_equals( $stored_h, hash_hmac( 'sha256', $raw, self::signing_key() ) ) ) {
            return 'quota_exceeded';
        }
        $grace = json_decode( $raw, true );
        if ( ! is_array( $grace ) || (int) ( $grace['expires'] ?? 0 ) < time() ) {
            return 'quota_exceeded';
        }
        if ( empty( $grace['allowed'] ) ) {
            return isset( $grace['reason'] ) ? (string) $grace['reason'] : 'quota_exceeded';
        }
        return true;
    }

    /** Persist the last server quota response as a grace cache, HMAC-protected. */
    private static function store_grace_token( array $body ): void {
        $grace = [
            'allowed'   => ! empty( $body['allowed'] ),
            'reason'    => isset( $body['reason'] ) ? (string) $body['reason'] : '',
            'remaining' => (int) ( $body['remaining'] ?? 0 ),
            'expires'   => time() + self::GRACE_TTL,
        ];
        $raw = (string) wp_json_encode( $grace );
        self::update_opt( self::OPT_GRACE,   $raw );
        self::update_opt( self::OPT_GRACE_H, hash_hmac( 'sha256', $raw, self::signing_key() ) );
    }

    /** Anonymised install fingerprint — 16 hex chars, stable per domain + admin email. */
    private static function domain_hash(): string {
        return substr(
            hash( 'sha256', home_url() . get_option( 'admin_email', '' ) ),
            0, 16
        );
    }

    // -------------------------------------------------------------------------
    // Phone-home telemetry — fire-and-forget
    // Server holds the authoritative usage count. Non-blocking (0.01s timeout).
    // -------------------------------------------------------------------------

    /**
     * Report a process failure to the telemetry server.
     * Fire-and-forget — non-blocking, never throws.
     */
    public static function report_failure( string $err_type, int $status = 0, string $detail = '' ): void {
        $body = [
            'd' => self::domain_hash(),
            'f' => $err_type,
        ];
        // 's' (status) and 'g' (detail) — 'e' is already the consumer flag on this endpoint.
        if ( $status > 0 ) {
            $body['s'] = $status;
        }
        if ( $detail !== '' ) {
            $body['g'] = substr( $detail, 0, 200 );
        }
        wp_remote_post(
            self::TELEMETRY_URL,
            [
                'body'      => $body,
                'timeout'   => 0.01,
                'blocking'  => false,
                'sslverify' => true,
            ]
        );
    }

    /**
     * Report a funnel/adoption event to the telemetry server — pure client-side
     * interactions with no other server round-trip (quota-wall reaction, GSC connect).
     * Fire-and-forget — non-blocking, never throws.
     */
    public static function report_funnel_event( string $event ): void {
        wp_remote_post(
            self::TELEMETRY_URL,
            [
                'body'      => [
                    'd'  => self::domain_hash(),
                    'ev' => $event,
                ],
                'timeout'   => 0.01,
                'blocking'  => false,
                'sslverify' => true,
            ]
        );
    }

    // -------------------------------------------------------------------------
    // Security log — local tamper event storage for the Reports dashboard
    // -------------------------------------------------------------------------

    /** Return the full security event log (newest first). */
    public static function get_security_log(): array {
        return (array) self::get_opt( self::OPT_SEC_LOG, [] );
    }

    /** Wipe the security log. Admin-only — call from trusted context. */
    public static function clear_security_log(): void {
        self::update_opt( self::OPT_SEC_LOG, [] );
    }

    private static function log_tamper( string $code ): void {
        $label_map = [
            'counter_reset'       => 'Quota counter reset detected — counter was zeroed after reaching the ceiling',
            'hwm_tamper'          => 'High water mark tampered — HWM hash integrity check failed',
            'counter_tamper'      => 'Counter hash mismatch — counter was modified without updating its HMAC',
            'credit_tamper'       => 'Credit balance tampered — balance HMAC integrity check failed',
            'credit_total_tamper' => 'Total purchased tampered — ceiling HMAC integrity check failed',
            'credit_inflation'    => 'Credit inflation detected — balance exceeds total credits ever purchased',
        ];
        $log = self::get_security_log();
        array_unshift( $log, [
            'time'  => current_time( 'mysql' ),
            'code'  => $code,
            'label' => $label_map[ $code ] ?? $code,
            'url'   => home_url(),
        ] );
        if ( count( $log ) > self::SEC_LOG_MAX ) {
            $log = array_slice( $log, 0, self::SEC_LOG_MAX );
        }
        self::update_opt( self::OPT_SEC_LOG, $log );
    }

    /**
     * Fire a tamper-detection ping when abuse is suspected.
     * Codes: counter_reset | hwm_tamper | counter_tamper
     */
    private static function ping_tamper( string $code ): void {
        $domain_hash = self::domain_hash();
        // Log locally for the Reports dashboard.
        self::log_tamper( $code );

        wp_remote_post( self::TELEMETRY_URL, [
            'blocking'  => false,
            'timeout'   => 0.01,
            'sslverify' => true,
            'body'      => [
                'd' => $domain_hash,
                'v' => PIPEBU_VERSION,
                'x' => $code,   // tamper signal
            ],
        ] );
    }

    /**
     * @param int   $count    Lifetime generation count (now consistent across both tiers).
     * @param array $features Feature-adoption flags for this generation — see keys below.
     *                        Sent as one JSON blob ('m') rather than minting a new single-letter
     *                        POST key per flag, on purpose: usage.php's own 'e' field already got
     *                        overloaded once (consumer flag vs. this file's old balance-as-'c' bug,
     *                        see 172nd lessons_learned addendum) — one namespaced field closes off
     *                        that whole collision class instead of requiring a grep every add.
     * @param bool  $is_paid  True if this install has a paid license.
     * @param int   $balance  Remaining credit balance (paid installs only).
     */
    private static function ping_home( int $count, array $features = [], bool $is_paid = false, int $balance = 0 ): void {
        $domain_hash = self::domain_hash();

        // Email domain classification — flags consumer vs branded installs.
        // Free signal: real store owners use branded email. Does not block anyone.
        $admin_email     = strtolower( get_option( 'admin_email', '' ) );
        $email_domain    = ltrim( strrchr( $admin_email, '@' ), '@' );
        $consumer_domains = [
            'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk',
            'hotmail.com', 'hotmail.co.uk', 'outlook.com', 'live.com',
            'icloud.com', 'me.com', 'mac.com', 'aol.com',
            'protonmail.com', 'proton.me', 'mail.com', 'ymail.com',
        ];
        $is_consumer = in_array( $email_domain, $consumer_domains, true ) ? 1 : 0;

        $body = [
            'd' => $domain_hash,
            'c' => $count,
            'v' => PIPEBU_VERSION,
            'e' => $is_consumer,  // 1 = consumer email, 0 = branded
            'p' => $is_paid ? 1 : 0,
        ];
        if ( $is_paid ) {
            $body['b'] = $balance;
        }
        if ( ! empty( $features ) ) {
            $body['m'] = wp_json_encode( $features );
        }

        wp_remote_post( self::TELEMETRY_URL, [
            'blocking'  => false,
            'timeout'   => 0.01,
            'sslverify' => true,
            'body'      => $body,
        ] );
    }
}
