<?php
/**
 * Admin AJAX handlers (nonced, capability-checked).
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Ajax {

    const NONCE_ACTION = 'seoforge_admin';

    public static function register( Seoforge_Loader $loader ) {
        $loader->add_action( 'wp_ajax_seoforge_test_mail',     __CLASS__, 'ajax_test_mail' );
        $loader->add_action( 'wp_ajax_seoforge_cleanup_now',   __CLASS__, 'ajax_cleanup_now' );
        $loader->add_action( 'wp_ajax_seoforge_reset_visits',  __CLASS__, 'ajax_reset_visits' );
        $loader->add_action( 'wp_ajax_seoforge_reset_404',     __CLASS__, 'ajax_reset_404' );
        $loader->add_action( 'wp_ajax_seoforge_resolve_404',   __CLASS__, 'ajax_resolve_404' );
        $loader->add_action( 'wp_ajax_seoforge_delete_redir',  __CLASS__, 'ajax_delete_redirect' );
        $loader->add_action( 'wp_ajax_seoforge_toggle_redir',  __CLASS__, 'ajax_toggle_redirect' );
        $loader->add_action( 'wp_ajax_seoforge_analyze_post',  __CLASS__, 'ajax_analyze_post' );
        $loader->add_action( 'wp_ajax_seoforge_backfill_scores', __CLASS__, 'ajax_backfill_scores' );
        // License activation now lives entirely inside Freemius — no AJAX of ours.
        $loader->add_action( 'wp_ajax_seoforge_migrate_start',    __CLASS__, 'ajax_migrate_start' );
        $loader->add_action( 'wp_ajax_seoforge_migrate_status',   __CLASS__, 'ajax_migrate_status' );
        $loader->add_action( 'wp_ajax_seoforge_ai_test',          __CLASS__, 'ajax_ai_test' );
        $loader->add_action( 'wp_ajax_seoforge_ai_suggest',       __CLASS__, 'ajax_ai_suggest' );
        $loader->add_action( 'wp_ajax_seoforge_geo_drill',        __CLASS__, 'ajax_geo_drill' );
        $loader->add_action( 'wp_ajax_seoforge_verify_reconcile', __CLASS__, 'ajax_verify_reconcile' );
    }

    /**
     * One time-budgeted chunk of a log-reconciliation run (see
     * Seoforge_Log_Reconciler::reconcile()'s own docblock for the
     * resumable-streaming design). The browser calls this repeatedly,
     * feeding back `offset` + `accumulator` from the previous response,
     * until `done` is true — the same loop shape as
     * ajax_backfill_scores()/`#sf-backfill-scores` in admin.js, just with a
     * server-held time budget per call instead of a fixed row batch, since a
     * log line's parse cost isn't uniform the way "analyze one post" is.
     *
     * Reconciliation always targets the CURRENT blog (Seoforge_Utils::
     * current_blog_id()), never a `target_blog_id` a network admin might be
     * viewing gaps/chain for elsewhere on the tab: the configured log-path
     * setting is a per-site wp_option, and resolving another site's own
     * option here would need a switch_to_blog() this endpoint doesn't do.
     * Gap/chain/determinism checks don't have that constraint (they only
     * ever read the shared wp_seoforge_history table by blog_id).
     */
    public static function ajax_verify_reconcile() {
        self::guard();

        $blog_id = Seoforge_Utils::current_blog_id();
        $format  = ( isset( $_POST['format'] ) && Seoforge_Log_Reconciler::FORMAT_COMBINED_XFF === $_POST['format'] )
            ? Seoforge_Log_Reconciler::FORMAT_COMBINED_XFF
            : Seoforge_Log_Reconciler::FORMAT_COMBINED;
        $source  = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : 'configured';
        $offset  = isset( $_POST['offset'] ) ? max( 0, (int) $_POST['offset'] ) : 0;

        $accumulator = null;
        if ( isset( $_POST['accumulator'] ) ) {
            $decoded = json_decode( wp_unslash( $_POST['accumulator'] ), true ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
            if ( is_array( $decoded ) ) {
                $accumulator = $decoded;
            }
        }

        // TIMEZONE DECISION (see WORK-1.10.0-VERIFY.md): the admin's From/To
        // inputs carry no zone of their own (`datetime-local`), so they are
        // interpreted in WordPress's configured timezone — the least
        // surprising choice for an admin typing a date+time into wp-admin —
        // and converted here to a UTC epoch. Both comparison sides are then
        // UTC: `visits.created_at` is written with current_time('mysql', 1)
        // (already UTC), and each nginx log line carries its own explicit
        // numeric offset (parsed in Seoforge_Log_Reconciler::line_timestamp(),
        // never assumed to match the server's or WordPress's zone). Get this
        // wrong and the whole comparison silently drifts by the zone's offset.
        $start_raw = isset( $_POST['start'] ) ? sanitize_text_field( wp_unslash( $_POST['start'] ) ) : '';
        $end_raw   = isset( $_POST['end'] ) ? sanitize_text_field( wp_unslash( $_POST['end'] ) ) : '';
        $tz        = wp_timezone();
        $start_dt  = DateTime::createFromFormat( 'Y-m-d H:i', $start_raw, $tz );
        $end_dt    = DateTime::createFromFormat( 'Y-m-d H:i', $end_raw, $tz );
        if ( ! $start_dt || ! $end_dt ) {
            wp_send_json_error( array( 'message' => __( 'Invalid date/time range.', 'brainwerk-seo-suite' ) ), 400 );
        }
        $start_ts = $start_dt->getTimestamp();
        $end_ts   = $end_dt->getTimestamp();
        if ( $end_ts <= $start_ts ) {
            wp_send_json_error( array( 'message' => __( 'The end must be after the start.', 'brainwerk-seo-suite' ) ), 400 );
        }
        // Only the UI's own quick-range/date inputs should ever produce a
        // range this wide; reject a manipulated request rather than letting
        // one AJAX call be pointed at an unbounded multi-year scan.
        if ( ( $end_ts - $start_ts ) > YEAR_IN_SECONDS ) {
            wp_send_json_error( array( 'message' => __( 'Range too wide — please reconcile at most one year at a time.', 'brainwerk-seo-suite' ) ), 400 );
        }

        if ( 'uploaded' === $source ) {
            $state    = Seoforge_Verify::get_ui_state( $blog_id );
            $filename = isset( $state['uploaded_log_filename'] ) ? $state['uploaded_log_filename'] : '';
            $resolved = Seoforge_Log_Reconciler::resolve_uploaded_path( $filename, Seoforge_Log_Reconciler::private_upload_dir() );
        } else {
            if ( is_multisite() && ! Seoforge_Utils::can_view_network_scope() ) {
                wp_send_json_error( array( 'message' => __( 'Only network administrators may reconcile against a server log path.', 'brainwerk-seo-suite' ) ), 403 );
            }
            $configured = (string) Seoforge_Settings::get( 'verify_nginx_log_path', '' );
            $resolved   = Seoforge_Log_Reconciler::resolve_log_path( $configured );
        }
        if ( empty( $resolved['ok'] ) ) {
            wp_send_json_error( array( 'message' => self::verify_log_error_message( $resolved['error'] ) ), 400 );
        }

        $excluded_paths = (array) Seoforge_Settings::get( 'tracking_exclude_paths', array() );
        $budget         = (int) apply_filters( 'seoforge_verify_reconcile_budget', 15 );

        $result = Seoforge_Log_Reconciler::reconcile( $resolved['path'], $start_ts, $end_ts, $format, $excluded_paths, $offset, $accumulator, $budget );

        if ( $result['done'] ) {
            $result['comparison'] = Seoforge_Log_Reconciler::compare_to_visits( $result, $blog_id, $start_ts, $end_ts );
        }

        wp_send_json( array( 'ok' => true, 'result' => $result ) );
    }

    private static function verify_log_error_message( $error ) {
        $messages = array(
            'empty'           => __( 'No log path is configured yet.', 'brainwerk-seo-suite' ),
            'traversal'       => __( 'That path is not allowed.', 'brainwerk-seo-suite' ),
            'not_found'       => __( 'The configured log file does not exist.', 'brainwerk-seo-suite' ),
            'not_readable'    => __( 'The web server user cannot read that file — check its file permissions.', 'brainwerk-seo-suite' ),
            'invalid_name'    => __( 'No uploaded log file is on record — upload one first.', 'brainwerk-seo-suite' ),
            'no_upload_dir'   => __( 'The upload directory is unavailable.', 'brainwerk-seo-suite' ),
            'outside_allowed' => __( 'The uploaded file is no longer in the expected location — upload it again.', 'brainwerk-seo-suite' ),
        );
        return isset( $messages[ $error ] ) ? $messages[ $error ] : __( 'Could not open the log file.', 'brainwerk-seo-suite' );
    }

    /**
     * Region + city drill-down for one country (used by the Visitors tab).
     */
    public static function ajax_geo_drill() {
        self::guard();
        // Region/city detail is a Pro feature (see admin/views/page-visitors.php);
        // the Free build's UI never links here, but the endpoint itself must
        // enforce the gate too, not just hide the drill-down control.
        if ( ! ( class_exists( 'Seoforge_License' ) && Seoforge_License::is_pro() ) ) {
            wp_send_json_error( array( 'message' => __( 'Region & city detail is a Pro feature.', 'brainwerk-seo-suite' ), 'pro' => true ), 403 );
        }
        $country = isset( $_POST['country'] ) ? strtoupper( substr( sanitize_text_field( wp_unslash( $_POST['country'] ) ), 0, 2 ) ) : '';
        $hours   = isset( $_POST['hours'] ) ? max( 1, min( 8760, (int) $_POST['hours'] ) ) : 168;
        if ( '' === $country ) {
            wp_send_json_error( array( 'message' => 'bad country' ), 400 );
        }
        $scope   = Seoforge_Utils::stats_scope();
        $regions = Seoforge_Stats::geo_regions( $country, $hours, 15, $scope );
        $cities  = Seoforge_Stats::geo_cities( $country, $hours, 15, $scope );
        wp_send_json( array(
            'ok'      => true,
            'country' => $country,
            'regions' => $regions,
            'cities'  => $cities,
        ) );
    }

    public static function ajax_ai_test() {
        self::guard();
        $r = Seoforge_AI::test_connection();
        if ( is_wp_error( $r ) ) {
            wp_send_json_error( array( 'message' => $r->get_error_message() ), 400 );
        }
        wp_send_json( array( 'ok' => true, 'reply' => isset( $r['text'] ) ? $r['text'] : '' ) );
    }

    public static function ajax_ai_suggest() {
        self::guard();
        $kind    = isset( $_POST['kind'] ) ? sanitize_key( wp_unslash( $_POST['kind'] ) ) : '';
        $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0;
        if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
            wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
        }
        $map = array(
            'titles'        => 'suggest_titles',
            'descriptions'  => 'suggest_descriptions',
            'focus'         => 'suggest_focus_keyword',
            'links'         => 'suggest_internal_links',
        );
        if ( ! isset( $map[ $kind ] ) ) {
            wp_send_json_error( array( 'message' => 'unknown kind' ), 400 );
        }
        $r = call_user_func( array( 'Seoforge_AI_Suggester', $map[ $kind ] ), $post_id );
        if ( is_wp_error( $r ) ) {
            wp_send_json_error( array( 'message' => $r->get_error_message() ), 400 );
        }
        wp_send_json( array( 'ok' => true, 'kind' => $kind, 'data' => $r ) );
    }

    public static function ajax_migrate_start() {
        self::guard();
        $source = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : '';
        $res = Seoforge_Migrate::start( $source );
        if ( is_wp_error( $res ) ) {
            wp_send_json_error( array( 'message' => $res->get_error_message() ), 400 );
        }
        // Run the first batch synchronously so the user sees instant progress
        // even if WP-Cron is slow to fire on a quiet site.
        Seoforge_Migrate::process_batch();
        wp_send_json( array( 'ok' => true, 'state' => Seoforge_Migrate::get_state() ) );
    }

    public static function ajax_migrate_status() {
        self::guard();
        wp_send_json( array( 'ok' => true, 'state' => Seoforge_Migrate::get_state() ) );
    }

    private static function guard() {
        if ( ! check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => 'Bad nonce' ), 403 );
        }
        if ( ! current_user_can( 'manage_options' ) && ! current_user_can( 'manage_network_options' ) ) {
            wp_send_json_error( array( 'message' => 'Forbidden' ), 403 );
        }
        // Rate limit: 60 admin AJAX actions per minute per user. Stops a runaway
        // browser tab or scripted brute force from torching the DB.
        $uid = get_current_user_id();
        $key = 'seoforge_rl_' . $uid . '_' . gmdate( 'YmdHi' );
        $hits = (int) get_transient( $key );
        if ( $hits >= 60 ) {
            wp_send_json_error( array( 'message' => 'Rate limit. Try again in a minute.' ), 429 );
        }
        set_transient( $key, $hits + 1, MINUTE_IN_SECONDS );
    }

    public static function ajax_test_mail() {
        self::guard();
        $ok = Seoforge_Tools::send_test_mail();
        wp_send_json( array( 'ok' => (bool) $ok ) );
    }

    public static function ajax_cleanup_now() {
        self::guard();
        Seoforge_Tools::cleanup_now();
        wp_send_json( array( 'ok' => true ) );
    }

    public static function ajax_reset_visits() {
        self::guard();
        Seoforge_Tools::reset_visits();
        wp_send_json( array( 'ok' => true ) );
    }

    public static function ajax_reset_404() {
        self::guard();
        Seoforge_Tools::reset_404();
        wp_send_json( array( 'ok' => true ) );
    }

    public static function ajax_resolve_404() {
        self::guard();
        $id = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0;
        Seoforge_Not_Found::mark_resolved( $id, true );
        wp_send_json( array( 'ok' => true ) );
    }

    public static function ajax_delete_redirect() {
        self::guard();
        $id = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0;
        Seoforge_Redirects::delete( $id );
        wp_send_json( array( 'ok' => true ) );
    }

    public static function ajax_toggle_redirect() {
        self::guard();
        $id    = isset( $_POST['id'] ) ? (int) $_POST['id'] : 0;
        $state = Seoforge_Redirects::toggle( $id );
        wp_send_json( array( 'ok' => true, 'is_active' => $state ) );
    }

    public static function ajax_analyze_post() {
        self::guard();
        $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0;
        if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
            wp_send_json_error( array( 'message' => 'No post id' ), $post_id ? 403 : 400 );
        }
        $result = Seoforge_Analyzer::analyze_and_save( $post_id );
        wp_send_json( $result );
    }

    /**
     * One batch of the "recalculate all scores" backfill: analyzes up to
     * BACKFILL_BATCH published posts that have no real score yet (no meta row,
     * or a bare row with score 0 and no payload), then reports how many remain.
     * The browser calls this repeatedly until `remaining` reaches 0.
     */
    const BACKFILL_BATCH = 25;

    public static function ajax_backfill_scores() {
        self::guard();

        $ids = self::unscored_post_ids( self::BACKFILL_BATCH );
        $processed = 0;
        foreach ( $ids as $id ) {
            Seoforge_Analyzer::analyze_and_save( (int) $id );
            $processed++;
        }
        $remaining = self::count_unscored();

        wp_send_json( array(
            'ok'        => true,
            'processed' => $processed,
            'remaining' => $remaining,
        ) );
    }

    /**
     * IDs of published, public posts lacking a real SEO score.
     *
     * @param int $limit Max IDs to return.
     * @return int[]
     */
    private static function unscored_post_ids( $limit ) {
        global $wpdb;
        $types = array_values( get_post_types( array( 'public' => true, 'show_ui' => true ) ) );
        if ( empty( $types ) ) {
            return array();
        }
        $meta_t       = Seoforge_Utils::table( 'meta' );
        $blog         = Seoforge_Utils::current_blog_id();
        $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
        // The IN() post-type list uses bound %s placeholders (the documented
        // prepare() exception; an IN list cannot use %i). %i binds the table.
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_col( $wpdb->prepare(
            "SELECT p.ID
             FROM {$wpdb->posts} p
             LEFT JOIN %i m ON ( m.post_id = p.ID AND m.blog_id = %d )
             WHERE p.post_status = 'publish' AND p.post_type IN ( {$placeholders} )
               AND ( m.id IS NULL OR ( ( m.score = 0 OR m.score IS NULL ) AND ( m.score_payload IS NULL OR m.score_payload = '' ) ) )
             ORDER BY p.ID ASC
             LIMIT %d",
            array_merge( array( $meta_t, $blog ), $types, array( (int) $limit ) )
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        return array_map( 'intval', (array) $rows );
    }

    /** Count of published, public posts lacking a real SEO score. */
    private static function count_unscored() {
        global $wpdb;
        $types = array_values( get_post_types( array( 'public' => true, 'show_ui' => true ) ) );
        if ( empty( $types ) ) {
            return 0;
        }
        $meta_t       = Seoforge_Utils::table( 'meta' );
        $blog         = Seoforge_Utils::current_blog_id();
        $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $count = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*)
             FROM {$wpdb->posts} p
             LEFT JOIN %i m ON ( m.post_id = p.ID AND m.blog_id = %d )
             WHERE p.post_status = 'publish' AND p.post_type IN ( {$placeholders} )
               AND ( m.id IS NULL OR ( ( m.score = 0 OR m.score IS NULL ) AND ( m.score_payload IS NULL OR m.score_payload = '' ) ) )",
            array_merge( array( $meta_t, $blog ), $types )
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        return $count;
    }
}
