<?php
/**
 * AI-Visibility Tracker — state store + prompt seeding.
 *
 * Persists the visibility-check state (seeded prompts, run history, latest
 * run) in a single option and builds the initial prompt set from the
 * business profile already captured by Business Settings / the Setup
 * Wizard — the same `sitestaffr_settings` option Blog Agent reads for its
 * own business_context (see SiteStaffr_Blog_Manager::build_generation_payload()).
 * No new DB table — options API only.
 *
 * seed_prompts() first asks the middleware's signed `/visibility/seed-prompts`
 * endpoint to generate branded + unbranded prompts from the client's
 * free-text `business_profile` (the same rich profile Blog Agent uses to
 * write posts — see request_seed_prompts()); the middleware isn't limited to
 * structured fields the way the local fallback is, so it can produce
 * unbranded prompts today even though Business Settings only captures
 * free-text business_name / business_context / business_profile (no
 * structured services or city/area field yet).
 *
 * If that call fails or returns nothing usable, seed_prompts() falls back to
 * seed_prompts_from_settings(), which reads forward-compatible 'services',
 * 'city', and 'area' keys (the exact placeholders named in its prompt
 * templates) so local unbranded seeding activates automatically the moment
 * a future Business Profile update adds them; until then it degrades
 * gracefully to branded-only prompts rather than emitting a hollow
 * "best  in " prompt. This guarantees seeding never breaks even if the
 * middleware is unreachable or the account isn't entitled.
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 * @since      1.36.0
 */

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

class SiteStaffr_Visibility {

    /** Option that stores the full visibility state (prompts/history/latest). */
    const STATE_OPTION = 'sitestaffr_visibility_state';

    /** Prompts are capped at 10 (mirrors the middleware's MAX_PROMPTS in routes/visibility.js). */
    const MAX_PROMPTS = 10;

    /** Keep the last 12 runs of summary history (roughly a year of monthly checks). */
    const MAX_HISTORY = 12;

    /**
     * Read the stored visibility state, defaulting when unset or corrupted.
     *
     * @since 1.36.0
     * @return array { prompts: array, history: array, latest: array|null }
     */
    public static function get_state() {
        $state = get_option( self::STATE_OPTION, array() );
        if ( ! is_array( $state ) ) {
            $state = array();
        }

        return array_merge(
            array(
                'prompts' => array(),
                'history' => array(),
                'latest'  => null,
            ),
            $state
        );
    }

    /**
     * Persist the visibility state. Autoload OFF — this option is only read
     * on the admin's own AI-Visibility page, never on every request.
     *
     * @since 1.36.0
     * @param array $state Full state array to store.
     */
    public static function save_state( array $state ) {
        update_option( self::STATE_OPTION, $state, false );
    }

    /**
     * Build the initial prompt set. Tries the middleware's
     * `/visibility/seed-prompts` endpoint first — it generates branded +
     * unbranded prompts from the client's free-text business profile (richer
     * than the local heuristics below, since it isn't limited to the
     * forward-compatible services/city/area keys). Falls back to the local
     * branded/services logic whenever the middleware call fails or returns
     * nothing usable, so seeding never breaks even if the middleware is
     * unreachable or the account isn't entitled.
     *
     * @since 1.36.0
     * @return array List of { id, text, type } prompt entries.
     */
    public static function seed_prompts() {
        $remote = self::request_seed_prompts();
        if ( ! is_wp_error( $remote ) && is_array( $remote ) ) {
            $prompts = self::sanitize_seeded_prompts( $remote );
            if ( ! empty( $prompts ) ) {
                return $prompts;
            }
        }

        return self::seed_prompts_from_settings();
    }

    /**
     * Local fallback prompt seeding: 1-2 branded prompts ("What is X?", "Is X
     * any good?") plus unbranded prompts from services x location, when that
     * data is present. Capped at 10. Used when the middleware's
     * `/visibility/seed-prompts` call fails or returns zero valid prompts.
     *
     * @since 1.36.0
     * @return array List of { id, text, type } prompt entries.
     */
    private static function seed_prompts_from_settings() {
        $settings = get_option( 'sitestaffr_settings', array() );
        if ( ! is_array( $settings ) ) {
            $settings = array();
        }

        $prompts = array();

        $business_name = isset( $settings['business_name'] ) ? trim( stripslashes( (string) $settings['business_name'] ) ) : '';
        if ( '' !== $business_name ) {
            $prompts[] = self::make_prompt( sprintf( 'What is %s?', $business_name ), 'branded' );
            $prompts[] = self::make_prompt( sprintf( 'Is %s any good?', $business_name ), 'branded' );
        }

        $services = self::get_services( $settings );
        $city     = self::get_location_field( $settings, 'city' );
        $area     = self::get_location_field( $settings, 'area' );
        // Each location template has its own placeholder, but a single
        // configured location should still seed both — fall back to
        // whichever of city/area is present when the other is empty.
        if ( '' === $city ) {
            $city = $area;
        }
        if ( '' === $area ) {
            $area = $city;
        }

        foreach ( $services as $service ) {
            if ( '' !== $city ) {
                $prompts[] = self::make_prompt( sprintf( 'best %s in %s', $service, $city ), 'unbranded' );
            }
            if ( '' !== $area ) {
                $prompts[] = self::make_prompt( sprintf( 'who offers %s near %s', $service, $area ), 'unbranded' );
            }
        }

        return array_slice( $prompts, 0, self::MAX_PROMPTS );
    }

    /**
     * Sanitize + validate the raw `prompts` array returned by the
     * middleware's `/visibility/seed-prompts` endpoint. The middleware
     * response is untrusted: every text field is sanitized, `type` is
     * whitelisted to branded|unbranded (defaulting to unbranded), empty-text
     * entries are dropped, and the result is capped at MAX_PROMPTS.
     *
     * @since 1.36.0
     * @param array $raw Decoded `prompts` array from the middleware response.
     * @return array List of { id, text, type } prompt entries.
     */
    private static function sanitize_seeded_prompts( array $raw ) {
        $prompts = array();

        foreach ( $raw as $entry ) {
            if ( ! is_array( $entry ) || ! isset( $entry['text'] ) ) {
                continue;
            }

            $text = sanitize_text_field( (string) $entry['text'] );
            if ( '' === $text ) {
                continue;
            }

            $type = isset( $entry['type'] ) ? (string) $entry['type'] : 'unbranded';
            $type = in_array( $type, array( 'branded', 'unbranded' ), true ) ? $type : 'unbranded';

            $prompts[] = self::make_prompt( $text, $type );

            if ( count( $prompts ) >= self::MAX_PROMPTS ) {
                break;
            }
        }

        return $prompts;
    }

    /**
     * Ask the middleware's signed `/visibility/seed-prompts` endpoint to
     * generate branded + unbranded prompts from the client's free-text
     * business profile. Mirrors request_run()'s signing / identity sourcing
     * exactly (same site-token guard, same api-secret preflight, same
     * `post_signed_json_with_json_response()` transport) — see that
     * method's docblock for the shared-infrastructure rationale.
     *
     * `business_profile` is sourced the same way
     * SiteStaffr_Blog_Manager::get_content_business_description() reads the
     * Blog Agent's writing profile: the raw `business_profile` setting (the
     * "Detailed Business Profile" / "Detailed Profile (Blog Agent)" field —
     * storage is body-only, the summary lives separately in
     * `business_context`), defensively stripped of any legacy SUMMARY
     * section via SiteStaffr_Admin::strip_summary_section(). This is the
     * rich free-text profile Blog Agent uses to plan and write posts, not
     * the lean `business_context` summary used elsewhere for the live
     * conversation agent.
     *
     * @since 1.36.0
     * @return array|WP_Error Decoded `prompts` array on success; WP_Error on
     *                        any failure (missing config, transport error,
     *                        non-200 status, or invalid JSON shape).
     */
    private static function request_seed_prompts() {
        $site_token = get_option( 'sitestaffr_site_token', '' );
        if ( empty( $site_token ) ) {
            return new WP_Error( 'http', __( 'Site token not configured for middleware authentication.', 'sitestaffr' ) );
        }

        $preflight = SiteStaffr_Public_Middleware_Client::get_public_preflight_config(
            SiteStaffr_Public_Middleware_Client::get_current_api_secret()
        );
        if ( $preflight['missing_api_secret'] ) {
            return new WP_Error( 'http', SiteStaffr_Public_Middleware_Client::msg_config_missing_api_secret() );
        }
        if ( $preflight['missing_middleware_url'] ) {
            return new WP_Error( 'http', SiteStaffr_Public_Middleware_Client::msg_config_missing_middleware_url() );
        }

        $settings = get_option( 'sitestaffr_settings', array() );
        if ( ! is_array( $settings ) ) {
            $settings = array();
        }
        $business_name = isset( $settings['business_name'] ) ? stripslashes( (string) $settings['business_name'] ) : '';

        // Mirror SiteStaffr_Blog_Manager::get_content_business_description(): use the detailed
        // business_profile (body) when non-empty, but fall back to business_context (summary)
        // when the body is empty. This ensures the middleware gets usable text for most installs,
        // even when the optional detailed profile field is left blank.
        $summary = isset( $settings['business_context'] ) ? trim( (string) $settings['business_context'] ) : '';
        $body    = isset( $settings['business_profile'] ) ? trim( (string) $settings['business_profile'] ) : '';
        if ( '' !== $body && class_exists( 'SiteStaffr_Admin' ) ) {
            $body = SiteStaffr_Admin::strip_summary_section( $body );
        }
        $business_profile = '' !== $body ? $body : $summary;

        $site_url = get_site_url();

        $request_body = array(
            'site_token'       => $site_token,
            'site_url'         => $site_url,
            'business_name'    => $business_name,
            'business_profile' => $business_profile,
        );

        $payload = wp_json_encode( $request_body );

        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            SiteStaffr_Logger::legacy( 'SiteStaffr Visibility: seed-prompts request payload - ' . $payload );
        }

        $endpoint = SiteStaffr_Public_Middleware_Client::build_middleware_endpoint(
            $preflight['middleware_url'],
            SiteStaffr_Public_Middleware_Client::path_visibility_seed_prompts()
        );

        $response = SiteStaffr_Public_Middleware_Client::post_signed_json_with_json_response(
            $endpoint,
            $payload,
            $preflight['api_secret'],
            null,
            '',
            60,
            SiteStaffr_Public_Middleware_Client::extra_headers_json()
        );

        if ( is_wp_error( $response ) ) {
            return new WP_Error(
                'http',
                SiteStaffr_Public_Middleware_Client::msg_failed_to_connect_to_middleware_prefix() . SiteStaffr_Public_Middleware_Client::get_wp_http_error_message( $response )
            );
        }

        $status_code  = (int) $response['status_code'];
        $decoded_body = $response['decoded_body'];

        if ( 200 !== $status_code ) {
            return new WP_Error(
                'http',
                sprintf(
                    /* translators: %d is the HTTP status code returned by the middleware. */
                    __( 'Middleware returned HTTP %d.', 'sitestaffr' ),
                    $status_code
                )
            );
        }

        $validation = SiteStaffr_Public_Middleware_Client::validate_json_payload(
            $response['json_error'],
            $decoded_body,
            array( 'ok', 'prompts' )
        );
        if ( ! $validation['ok'] || empty( $decoded_body['ok'] ) || ! is_array( $decoded_body['prompts'] ) ) {
            return new WP_Error( 'http', SiteStaffr_Public_Middleware_Client::msg_invalid_middleware_response() );
        }

        return $decoded_body['prompts'];
    }

    /**
     * Ask the middleware for fresh prompt CANDIDATES that target the site's
     * visibility gaps. Gathers the gap signal the tracker already stores — the
     * currently-tracked prompt texts (so the middleware won't suggest
     * duplicates), the latest run's missed prompts, and the competitor domains
     * winning them — then returns sanitized, whitelisted, capped suggestions.
     *
     * @since 1.36.2
     * @return array|WP_Error List of { id, text, type } suggestions (possibly
     *                        empty when nothing new is worth adding); WP_Error
     *                        on transport/config failure.
     */
    public static function suggest_prompts() {
        $state = self::get_state();

        $existing = array();
        if ( ! empty( $state['prompts'] ) && is_array( $state['prompts'] ) ) {
            foreach ( $state['prompts'] as $p ) {
                if ( is_array( $p ) && ! empty( $p['text'] ) ) {
                    $existing[] = (string) $p['text'];
                }
            }
        }

        $missed = array();
        $competitors = array();
        $latest = ( isset( $state['latest'] ) && is_array( $state['latest'] ) ) ? $state['latest'] : array();
        if ( ! empty( $latest['prompts'] ) && is_array( $latest['prompts'] ) ) {
            foreach ( $latest['prompts'] as $p ) {
                if ( is_array( $p ) && empty( $p['cited'] ) && ! empty( $p['text'] ) ) {
                    $missed[] = (string) $p['text'];
                }
            }
        }
        if ( ! empty( $latest['competitorsAggregate'] ) && is_array( $latest['competitorsAggregate'] ) ) {
            foreach ( $latest['competitorsAggregate'] as $row ) {
                if ( is_array( $row ) && ! empty( $row['domain'] ) ) {
                    $competitors[] = (string) $row['domain'];
                }
            }
        }

        $remote = self::request_suggest_prompts( $existing, $missed, array_slice( $competitors, 0, 10 ) );
        if ( is_wp_error( $remote ) ) {
            return $remote;
        }

        return self::sanitize_seeded_prompts( is_array( $remote ) ? $remote : array() );
    }

    /**
     * POST the signed suggest-prompts request. Mirrors request_seed_prompts()'s
     * signing / identity sourcing / transport exactly (see its docblock); adds
     * the gap-signal fields the suggest endpoint reads.
     *
     * @since 1.36.2
     * @param string[] $existing_prompts Currently-tracked prompt texts.
     * @param string[] $missed_prompts   Latest run's uncited prompt texts.
     * @param string[] $competitors      Competitor domains from the latest run.
     * @return array|WP_Error Decoded `prompts` array on success; WP_Error on failure.
     */
    private static function request_suggest_prompts( $existing_prompts, $missed_prompts, $competitors ) {
        $site_token = get_option( 'sitestaffr_site_token', '' );
        if ( empty( $site_token ) ) {
            return new WP_Error( 'http', __( 'Site token not configured for middleware authentication.', 'sitestaffr' ) );
        }

        $preflight = SiteStaffr_Public_Middleware_Client::get_public_preflight_config(
            SiteStaffr_Public_Middleware_Client::get_current_api_secret()
        );
        if ( $preflight['missing_api_secret'] ) {
            return new WP_Error( 'http', SiteStaffr_Public_Middleware_Client::msg_config_missing_api_secret() );
        }
        if ( $preflight['missing_middleware_url'] ) {
            return new WP_Error( 'http', SiteStaffr_Public_Middleware_Client::msg_config_missing_middleware_url() );
        }

        $settings = get_option( 'sitestaffr_settings', array() );
        if ( ! is_array( $settings ) ) {
            $settings = array();
        }
        $business_name = isset( $settings['business_name'] ) ? stripslashes( (string) $settings['business_name'] ) : '';

        // Same profile sourcing as request_seed_prompts(): detailed profile body,
        // falling back to the business_context summary when the body is empty.
        $summary = isset( $settings['business_context'] ) ? trim( (string) $settings['business_context'] ) : '';
        $body    = isset( $settings['business_profile'] ) ? trim( (string) $settings['business_profile'] ) : '';
        if ( '' !== $body && class_exists( 'SiteStaffr_Admin' ) ) {
            $body = SiteStaffr_Admin::strip_summary_section( $body );
        }
        $business_profile = '' !== $body ? $body : $summary;

        $request_body = array(
            'site_token'       => $site_token,
            'site_url'         => get_site_url(),
            'business_name'    => $business_name,
            'business_profile' => $business_profile,
            'existing_prompts' => array_values( array_map( 'strval', (array) $existing_prompts ) ),
            'missed_prompts'   => array_values( array_map( 'strval', (array) $missed_prompts ) ),
            'competitors'      => array_values( array_map( 'strval', (array) $competitors ) ),
        );

        $payload = wp_json_encode( $request_body );

        $endpoint = SiteStaffr_Public_Middleware_Client::build_middleware_endpoint(
            $preflight['middleware_url'],
            SiteStaffr_Public_Middleware_Client::path_visibility_suggest_prompts()
        );

        $response = SiteStaffr_Public_Middleware_Client::post_signed_json_with_json_response(
            $endpoint,
            $payload,
            $preflight['api_secret'],
            null,
            '',
            60,
            SiteStaffr_Public_Middleware_Client::extra_headers_json()
        );

        if ( is_wp_error( $response ) ) {
            return new WP_Error(
                'http',
                SiteStaffr_Public_Middleware_Client::msg_failed_to_connect_to_middleware_prefix() . SiteStaffr_Public_Middleware_Client::get_wp_http_error_message( $response )
            );
        }

        $status_code  = (int) $response['status_code'];
        $decoded_body = $response['decoded_body'];

        if ( 200 !== $status_code ) {
            return new WP_Error(
                'http',
                sprintf(
                    /* translators: %d is the HTTP status code returned by the middleware. */
                    __( 'Middleware returned HTTP %d.', 'sitestaffr' ),
                    $status_code
                )
            );
        }

        $validation = SiteStaffr_Public_Middleware_Client::validate_json_payload(
            $response['json_error'],
            $decoded_body,
            array( 'ok', 'prompts' )
        );
        if ( ! $validation['ok'] || empty( $decoded_body['ok'] ) || ! is_array( $decoded_body['prompts'] ) ) {
            return new WP_Error( 'http', SiteStaffr_Public_Middleware_Client::msg_invalid_middleware_response() );
        }

        return $decoded_body['prompts'];
    }

    /**
     * Run a visibility check via the middleware's signed `/visibility/run`
     * endpoint. Mirrors the HMAC signing / identity sourcing used by
     * SiteStaffr_AI_Client and the widget realtime-session handler: same
     * `sitestaffr_site_token` / `sitestaffr_account_id` / `sitestaffr_installation_id`
     * options, same api-secret fallback chain (per-site, then global), same
     * `SiteStaffr_Public_Middleware_Client::post_signed_json_with_json_response()`
     * signed-transport helper (HMAC-SHA256 over "<timestamp>.<json-payload>").
     *
     * @since 1.36.0
     * @param string $run_type 'auto' or 'manual'.
     * @return array|WP_Error Decoded `run` array (with `rate_limit` attached
     *                        when present) on success; WP_Error with code
     *                        'rate_limited' | 'not_entitled' | 'http' on failure.
     */
    public static function request_run( $run_type ) {
        $run_type = in_array( $run_type, array( 'auto', 'manual' ), true ) ? $run_type : 'manual';

        $site_token = get_option( 'sitestaffr_site_token', '' );
        if ( empty( $site_token ) ) {
            return new WP_Error( 'http', __( 'Site token not configured for middleware authentication.', 'sitestaffr' ) );
        }

        // Same api-secret fallback chain as SiteStaffr_AI_Client::get_api_secret()
        // (per-site secret, then global secret) and the same missing-URL guard
        // used by the widget realtime-session handler.
        $preflight = SiteStaffr_Public_Middleware_Client::get_public_preflight_config(
            SiteStaffr_Public_Middleware_Client::get_current_api_secret()
        );
        if ( $preflight['missing_api_secret'] ) {
            return new WP_Error( 'http', SiteStaffr_Public_Middleware_Client::msg_config_missing_api_secret() );
        }
        if ( $preflight['missing_middleware_url'] ) {
            return new WP_Error( 'http', SiteStaffr_Public_Middleware_Client::msg_config_missing_middleware_url() );
        }

        $settings = get_option( 'sitestaffr_settings', array() );
        if ( ! is_array( $settings ) ) {
            $settings = array();
        }
        $business_name = isset( $settings['business_name'] ) ? stripslashes( (string) $settings['business_name'] ) : '';

        $site_url = get_site_url();
        $host     = wp_parse_url( $site_url, PHP_URL_HOST );
        $domains  = ( is_string( $host ) && '' !== $host ) ? array( $host ) : array();

        $account_id      = sanitize_text_field( (string) get_option( 'sitestaffr_account_id', '' ) );
        $installation_id = sanitize_text_field( (string) get_option( 'sitestaffr_installation_id', '' ) );

        $state   = self::get_state();
        $prompts = ( ! empty( $state['prompts'] ) && is_array( $state['prompts'] ) ) ? $state['prompts'] : self::seed_prompts();

        // Key order matches the data contract in docs/superpowers/plans/2026-07-21-ai-visibility-tracker.md
        // (site_token, site_url, account_id, installation_id, business_name, domains, run_type, prompts).
        $request_body = array(
            'site_token'      => $site_token,
            'site_url'        => $site_url,
            'account_id'      => $account_id,
            'installation_id' => $installation_id,
            'business_name'   => $business_name,
            'domains'         => $domains,
            'run_type'        => $run_type,
            'prompts'         => $prompts,
        );

        $payload = wp_json_encode( $request_body );

        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            SiteStaffr_Logger::legacy( 'SiteStaffr Visibility: request payload - ' . $payload );
        }

        $endpoint = SiteStaffr_Public_Middleware_Client::build_middleware_endpoint(
            $preflight['middleware_url'],
            SiteStaffr_Public_Middleware_Client::path_visibility_run()
        );

        $response = SiteStaffr_Public_Middleware_Client::post_signed_json_with_json_response(
            $endpoint,
            $payload,
            $preflight['api_secret'],
            null,
            '',
            60,
            SiteStaffr_Public_Middleware_Client::extra_headers_json()
        );

        if ( is_wp_error( $response ) ) {
            return new WP_Error(
                'http',
                SiteStaffr_Public_Middleware_Client::msg_failed_to_connect_to_middleware_prefix() . SiteStaffr_Public_Middleware_Client::get_wp_http_error_message( $response )
            );
        }

        $status_code  = (int) $response['status_code'];
        $decoded_body = $response['decoded_body'];

        if ( 200 === $status_code ) {
            $validation = SiteStaffr_Public_Middleware_Client::validate_json_payload(
                $response['json_error'],
                $decoded_body,
                array( 'ok', 'run' )
            );
            if ( ! $validation['ok'] || empty( $decoded_body['ok'] ) || ! is_array( $decoded_body['run'] ) ) {
                return new WP_Error( 'http', SiteStaffr_Public_Middleware_Client::msg_invalid_middleware_response() );
            }

            $run = $decoded_body['run'];
            if ( isset( $decoded_body['rate_limit'] ) ) {
                $run['rate_limit'] = $decoded_body['rate_limit'];
            }

            return $run;
        }

        $reason    = ( is_array( $decoded_body ) && isset( $decoded_body['reason'] ) ) ? (string) $decoded_body['reason'] : '';
        $error_data = array();
        if ( is_array( $decoded_body ) && isset( $decoded_body['resets_at'] ) ) {
            $error_data['resets_at'] = $decoded_body['resets_at'];
        }

        if ( 429 === $status_code || 'rate_limited' === $reason ) {
            return new WP_Error( 'rate_limited', __( 'Visibility check rate limit reached.', 'sitestaffr' ), $error_data );
        }

        if ( 403 === $status_code || 'not_entitled' === $reason ) {
            return new WP_Error( 'not_entitled', __( 'This site is not entitled to run visibility checks.', 'sitestaffr' ), $error_data );
        }

        return new WP_Error(
            'http',
            sprintf(
                /* translators: %d is the HTTP status code returned by the middleware. */
                __( 'Middleware returned HTTP %d.', 'sitestaffr' ),
                $status_code
            ),
            $error_data
        );
    }

    /**
     * (Re)schedule the monthly visibility-check cron tick to the first day of
     * next month (UTC). Mirrors
     * SiteStaffr_Blog_Manager::reschedule_autopilot() — single self-rescheduling
     * event, cleared and re-armed every time.
     *
     * Unlike Autopilot, there is no plugin-side "enabled" toggle to gate on:
     * Blog Agent itself (the feature this tracker rides along with) has no
     * plan restriction anywhere in this codebase — only its separate
     * Autopilot add-on is gated, via SiteStaffr_Blog_Manager::is_autopilot_allowed(),
     * to the business/pro tiers specifically. The design doc's Gating section
     * is explicit that the visibility tracker ships with "no new paywall" and
     * is available to every Blog-Agent-enabled account, so gating this on
     * is_autopilot_allowed() would wrongly exclude starter/trial accounts.
     * We therefore schedule unconditionally and let the middleware's
     * server-side entitlement check be the real gate: a non-entitled tick's
     * request_run() comes back as WP_Error( 'not_entitled', ... ), which
     * run_tick() logs and quietly skips (no history write, no email) before
     * rescheduling itself for next month — a harmless no-op call.
     *
     * Called on plugin activation (SiteStaffr_Activator::activate()) and
     * self-healed on admin_init (class-sitestaffr.php) if the tick ever goes
     * unscheduled (host cron drop, deactivate/reactivate). Also called from
     * the prompts AJAX save handler (`sitestaffr_visibility_update_prompts`)
     * so an edited prompt set doesn't wait out a stale schedule.
     *
     * @since 1.36.0
     */
    public static function reschedule() {
        wp_clear_scheduled_hook( 'sitestaffr_visibility_tick' );

        $next = self::get_next_month_start_utc();
        wp_schedule_single_event( $next, 'sitestaffr_visibility_tick' );
    }

    /**
     * Monthly cron tick: run an automatic visibility check, record it, email
     * the report, then reschedule for next month. Mirrors
     * SiteStaffr_Blog_Manager::run_autopilot_tick() — transient re-entrancy
     * lock, `finally`-guaranteed reschedule.
     *
     * record_run() is called BEFORE the email is sent: the email's trend
     * delta (SiteStaffr_Email_Notifications::calculate_visibility_trend_delta())
     * reads the previous history point, which only exists once this run has
     * been appended to history. Sending first would compare against itself.
     *
     * @since 1.36.0
     */
    public static function run_tick() {
        // Re-entrancy guard: WP-Cron can fire concurrently under traffic.
        if ( get_transient( 'sitestaffr_visibility_lock' ) ) {
            return;
        }
        set_transient( 'sitestaffr_visibility_lock', 1, 10 * MINUTE_IN_SECONDS );

        try {
            $run = self::request_run( 'auto' );

            if ( is_wp_error( $run ) ) {
                SiteStaffr_Logger::legacy( 'SiteStaffr Visibility: monthly tick failed - ' . $run->get_error_message() );
                return;
            }

            // Order matters — see docblock above.
            self::record_run( $run );

            if ( class_exists( 'SiteStaffr_Email_Notifications' ) ) {
                $notifier = new SiteStaffr_Email_Notifications();
                $notifier->send_visibility_report( $run );
            }
        } finally {
            delete_transient( 'sitestaffr_visibility_lock' );
            self::reschedule();
        }
    }

    /**
     * Timestamp for 00:00:00 UTC on the first day of next month, relative to
     * a given time (defaults to now).
     *
     * @since 1.36.0
     * @param int|null $from Unix timestamp to compute "next month" from; null for now.
     * @return int Unix timestamp.
     */
    private static function get_next_month_start_utc( $from = null ) {
        $from = null === $from ? time() : (int) $from;

        try {
            $tz  = new DateTimeZone( 'UTC' );
            $now = ( new DateTimeImmutable( '@' . $from ) )->setTimezone( $tz );
            $next = $now->modify( 'first day of next month' )->setTime( 0, 0, 0 );
            return $next->getTimestamp();
        } catch ( Exception $e ) {
            // Should be unreachable ('UTC' is always a valid timezone), but
            // never leave the tracker permanently unscheduled.
            return $from + 30 * DAY_IN_SECONDS;
        }
    }

    /**
     * Record a completed visibility run: append a lightweight summary to
     * history (trimmed to the last MAX_HISTORY entries) and store the full
     * run as `latest`.
     *
     * @since 1.36.0
     * @param array $run Full run payload; expected to carry date/score/byPlatform.
     */
    public static function record_run( array $run ) {
        $state = self::get_state();

        $summary = array(
            'date'       => isset( $run['date'] ) ? $run['date'] : current_time( 'mysql' ),
            'score'      => isset( $run['score'] ) ? $run['score'] : null,
            'byPlatform' => isset( $run['byPlatform'] ) ? $run['byPlatform'] : array(),
        );

        $history   = is_array( $state['history'] ) ? $state['history'] : array();
        $history[] = $summary;
        if ( count( $history ) > self::MAX_HISTORY ) {
            $history = array_slice( $history, -1 * self::MAX_HISTORY );
        }

        $state['history'] = $history;
        $state['latest']  = $run;

        self::save_state( $state );
    }

    /**
     * Build one prompt entry.
     *
     * @since 1.36.0
     * @param string $text Prompt text.
     * @param string $type 'branded'|'unbranded'.
     * @return array { id, text, type }
     */
    private static function make_prompt( $text, $type ) {
        return array(
            'id'   => uniqid( 'vp_', true ),
            'text' => $text,
            'type' => $type,
        );
    }

    /**
     * Structured services list, when present. See class docblock — Business
     * Settings has no services field today; this reads a forward-compatible
     * 'services' key (array, or comma-separated string) so seeding activates
     * automatically once one exists.
     *
     * @since 1.36.0
     * @param array $settings sitestaffr_settings option value.
     * @return string[] Trimmed, non-empty, de-duplicated service names.
     */
    private static function get_services( array $settings ) {
        $raw = isset( $settings['services'] ) ? $settings['services'] : '';

        if ( is_array( $raw ) ) {
            $items = $raw;
        } elseif ( is_string( $raw ) && '' !== trim( $raw ) ) {
            $items = explode( ',', $raw );
        } else {
            return array();
        }

        $items = array_map(
            function ( $item ) {
                return trim( (string) $item );
            },
            $items
        );
        $items = array_filter(
            $items,
            function ( $item ) {
                return '' !== $item;
            }
        );

        return array_values( array_unique( $items ) );
    }

    /**
     * A single location field ('city' or 'area'), when present. Same
     * forward-compatibility rationale as get_services().
     *
     * @since 1.36.0
     * @param array  $settings sitestaffr_settings option value.
     * @param string $key      'city' or 'area'.
     * @return string Trimmed value, or '' if absent.
     */
    private static function get_location_field( array $settings, $key ) {
        if ( ! empty( $settings[ $key ] ) && is_string( $settings[ $key ] ) ) {
            return trim( $settings[ $key ] );
        }
        return '';
    }
}
