<?php

/**
 * Spellmatic_Scanner
 * Handles single-post and full-site scan logic.
 *
 * Phase 1: Synchronous PHP stub scan (kept for single-post endpoint compat).
 * Phase 2: Browser-side Harper WASM scan.
 *   • scan_start()  — PHP prepares a list of post items with plain text.
 *   • scan_submit() — Browser POSTs detected errors; PHP stores them + returns results.
 * Phase 3: Builder-aware text extraction via Spellmatic_ContentExtractor.
 */
defined( 'ABSPATH' ) || exit;
class Spellmatic_Scanner {
    // No error cap on any version — all users see all results.
    // =========================================================================
    // PHASE 2 / 3 — Browser-side Harper scan
    // =========================================================================
    /**
     * Return all registered, UI-visible post types (excludes attachments).
     * Keyed by slug → plural label. Used by the dashboard scope selector
     * and by scan_start() when no explicit post_types param is supplied.
     */
    public static function get_public_post_types() : array {
        $types = get_post_types( [
            'public'  => true,
            'show_ui' => true,
        ], 'objects' );
        $result = [];
        foreach ( $types as $slug => $obj ) {
            if ( 'attachment' === $slug ) {
                continue;
            }
            $result[$slug] = $obj->labels->name ?? ucfirst( $slug );
        }
        return $result;
    }

    /**
     * Start a scan session and return all post items with plain text.
     * REST: POST /spellmatic/v1/scan/start
     *
     * Phase 3: Uses Spellmatic_ContentExtractor to handle Elementor, WPBakery,
     * Divi, and Avada content — no more post-count cap here. The free tier gate
     * is now applied in scan_submit() on the error results instead.
     *
     * Optional body param:
     *   post_types: string[]  — limit scan to these post type slugs (scope selector)
     *
     * Response:
     * {
     *   scan_id:     string,
     *   total_items: int,
     *   items: [
     *     { id: string, post_id: int, post_type: string, field: string, text: string },
     *     ...
     *   ]
     * }
     */
    public static function scan_start( WP_REST_Request $request ) : WP_REST_Response {
        $settings = get_option( 'spellmatic_settings', Spellmatic_Core::default_settings() );
        // Single-post scan: when scan_post_ids is provided, scan only those posts.
        $explicit_ids = $request->get_param( 'scan_post_ids' );
        if ( is_array( $explicit_ids ) && !empty( $explicit_ids ) ) {
            $post_ids = array_map( 'absint', $explicit_ids );
        } else {
            $requested_types = $request->get_param( 'post_types' );
            $post_ids = self::get_scannable_post_ids( $settings, ( is_array( $requested_types ) ? $requested_types : [] ) );
        }
        if ( empty( $post_ids ) ) {
            return new WP_REST_Response([
                'error' => 'No content found to scan.',
            ], 400);
        }
        // Build item list + per-post metadata (permalink, builder, builder edit URL).
        $items = [];
        $post_meta = [];
        // keyed by post_id (string for JSON compat)
        foreach ( $post_ids as $pid ) {
            $post = get_post( $pid );
            if ( !$post ) {
                continue;
            }
            foreach ( Spellmatic_ContentExtractor::get_items( $post ) as $item ) {
                $items[] = $item;
            }
            // Build the per-post meta that the dashboard needs for its link row.
            $builder = Spellmatic_ContentExtractor::detect_builder( $post );
            $permalink = get_permalink( $pid );
            $meta = [
                'title'              => ( get_the_title( $pid ) ?: 'Post #' . $pid ),
                'permalink'          => ( $permalink ?: '' ),
                'builder'            => $builder,
                'builder_edit_url'   => '',
                'builder_edit_label' => '',
            ];
            if ( 'elementor' === $builder ) {
                $meta['builder_edit_url'] = admin_url( 'post.php?post=' . $pid . '&action=elementor' );
                $meta['builder_edit_label'] = 'Edit with Elementor';
            } elseif ( 'avada' === $builder ) {
                // Avada Fusion Builder live-editor URL (frontend with builder param).
                $sep = ( strpos( $permalink, '?' ) !== false ? '&' : '?' );
                $meta['builder_edit_url'] = $permalink . $sep . 'fb-edit=1';
                $meta['builder_edit_label'] = 'Edit Live';
            } elseif ( 'divi' === $builder ) {
                $sep = ( strpos( $permalink, '?' ) !== false ? '&' : '?' );
                $meta['builder_edit_url'] = $permalink . $sep . 'et_fb=1&PageSpeed=off';
                $meta['builder_edit_label'] = 'Edit with Divi';
            }
            // WPBakery uses the standard WP editor — no separate builder URL needed.
            $post_meta[(string) $pid] = $meta;
        }
        if ( empty( $items ) ) {
            return new WP_REST_Response([
                'error' => 'No scannable text found.',
            ], 400);
        }
        $scan_id = wp_generate_uuid4();
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table insert for scan session tracking.
        $wpdb->insert( $wpdb->prefix . 'spellmatic_scans', [
            'id'          => $scan_id,
            'status'      => 'running',
            'total_items' => count( $post_ids ),
            'started_at'  => current_time( 'mysql' ),
        ] );
        set_transient( 'spellmatic_current_scan', $scan_id, HOUR_IN_SECONDS );
        return new WP_REST_Response([
            'scan_id'     => $scan_id,
            'total_items' => count( $items ),
            'items'       => $items,
            'post_meta'   => $post_meta,
        ], 200);
    }

    /**
     * Receive browser-detected errors, store them, and return the final results.
     * REST: POST /spellmatic/v1/scan/submit
     *
     * Expected body:
     * {
     *   scan_id: string,
     *   errors: [
     *     {
     *       post_id: int, post_type: string, field: string,
     *       type: string, original: string, suggestion: string,
     *       context: string, offset_start: int, offset_end: int
     *     },
     *     ...
     *   ]
     * }
     */
    public static function scan_submit( WP_REST_Request $request ) : WP_REST_Response {
        $scan_id = sanitize_text_field( $request->get_param( 'scan_id' ) ?? '' );
        $errors = $request->get_param( 'errors' );
        if ( empty( $scan_id ) ) {
            return new WP_REST_Response([
                'error' => 'Missing scan_id.',
            ], 400);
        }
        global $wpdb;
        // Verify the scan session exists and belongs to this request.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single-row lookup on custom table; not cacheable (scan sessions are transient).
        $scan = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}spellmatic_scans WHERE id = %s AND status = %s", $scan_id, 'running' ), ARRAY_A );
        if ( !$scan ) {
            return new WP_REST_Response([
                'error' => 'Scan session not found or already completed.',
            ], 404);
        }
        // Store each error — skip duplicates of open OR dismissed results.
        // An error is a duplicate if a result already exists with the
        // same post_id, original word, and character offsets that is
        // either still open or was previously dismissed by the user.
        // Previously fixed results ARE re-surfaced (the content was
        // changed but the error reappeared, so the user should see it).
        $error_count = 0;
        $skipped_dupes = 0;
        $results_table = $wpdb->prefix . 'spellmatic_results';
        if ( is_array( $errors ) ) {
            foreach ( $errors as $err ) {
                if ( !is_array( $err ) ) {
                    continue;
                }
                $post_id = (int) ($err['post_id'] ?? 0);
                $original = sanitize_text_field( $err['original'] ?? '' );
                $offset_start = (int) ($err['offset_start'] ?? 0);
                $offset_end = (int) ($err['offset_end'] ?? 0);
                // Check for an existing open or dismissed result with the same fingerprint.
                // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is safe ($wpdb->prefix + hardcoded string).
                $existing = $wpdb->get_var( $wpdb->prepare(
                    "SELECT id FROM {$results_table}\n                     WHERE post_id = %d\n                       AND original = %s\n                       AND offset_start = %d\n                       AND offset_end = %d\n                       AND status IN (%s, %s)\n                     LIMIT 1",
                    $post_id,
                    $original,
                    $offset_start,
                    $offset_end,
                    'open',
                    'dismissed'
                ) );
                if ( $existing ) {
                    $skipped_dupes++;
                    continue;
                }
                $wpdb->insert( $results_table, [
                    'post_id'      => $post_id,
                    'post_type'    => sanitize_key( $err['post_type'] ?? 'post' ),
                    'field'        => sanitize_key( $err['field'] ?? 'post_content' ),
                    'error_type'   => sanitize_key( $err['type'] ?? 'spelling' ),
                    'original'     => $original,
                    'suggestion'   => sanitize_text_field( $err['suggestion'] ?? '' ),
                    'context'      => sanitize_text_field( $err['context'] ?? '' ),
                    'offset_start' => $offset_start,
                    'offset_end'   => $offset_end,
                    'status'       => 'open',
                    'scan_id'      => $scan_id,
                ] );
                // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
                $error_count++;
            }
        }
        // Mark scan complete. error_count = newly inserted + duplicates already tracked.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Write operation on custom table.
        $wpdb->update( $wpdb->prefix . 'spellmatic_scans', [
            'status'        => 'complete',
            'scanned_items' => (int) $scan['total_items'],
            'error_count'   => $error_count + $skipped_dupes,
            'completed_at'  => current_time( 'mysql' ),
        ], [
            'id' => $scan_id,
        ] );
        // Return ALL open results (not just from this scan_id).
        // Because deduplication may have skipped errors that already exist
        // from a previous scan, we need to show every open result for the
        // posts that were part of this scan session.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fresh query required; scan results change every request.
        $results = $wpdb->get_results( $wpdb->prepare( "SELECT r.id, r.post_id, r.post_type, r.field, r.error_type,\n                        r.original, r.suggestion, r.context, r.status,\n                        p.post_title\n                 FROM {$wpdb->prefix}spellmatic_results r\n                 LEFT JOIN {$wpdb->posts} p ON r.post_id = p.ID\n                 WHERE r.status = %s\n                 ORDER BY r.post_id ASC", 'open' ), ARRAY_A );
        $total_found = count( $results );
        $capped = false;
        $plan = Spellmatic_License::get_plan();
        // ── Scan credit consumption (paid plans only) ──────────────────────
        $credits_remaining = null;
        return new WP_REST_Response([
            'scan_id'            => $scan_id,
            'status'             => 'complete',
            'total_items'        => (int) $scan['total_items'],
            'scanned_items'      => (int) $scan['total_items'],
            'error_count'        => $error_count,
            'duplicates_skipped' => $skipped_dupes,
            'percent'            => 100,
            'results'            => $results,
            'capped'             => $capped,
            'total_found'        => $total_found,
            'credits_remaining'  => $credits_remaining,
            'plan'               => $plan,
        ], 200);
    }

    // =========================================================================
    // PHASE 1 — Legacy sync scan (kept for backward compat with cached JS)
    // =========================================================================
    /**
     * Full-site scan: synchronous Phase 1 entry-point (legacy).
     * REST: POST /spellmatic/v1/scan/site
     *
     * Phase 2 scanning is browser-side; this now redirects callers to use
     * the new /scan/start + /scan/submit flow, returning a helpful message.
     */
    public static function start_site_scan( WP_REST_Request $request ) : WP_REST_Response {
        // Return a forward-compatible response so old cached dashboard.js handles it gracefully.
        // The new dashboard.js uses /scan/start instead.
        return new WP_REST_Response([
            'error'    => 'Please reload the Spellmatic dashboard. This endpoint has been replaced by /scan/start in Phase 2.',
            'redirect' => admin_url( 'admin.php?page=spellmatic' ),
        ], 410);
        // 410 Gone — permanent redirect signal
    }

    // =========================================================================
    // PHASE 1 — Single-post scan (kept for direct REST access / compatibility)
    // =========================================================================
    /**
     * Scan a single post and return errors.
     * REST: GET /spellmatic/v1/scan/post/{id}
     */
    public static function scan_post( WP_REST_Request $request ) : WP_REST_Response {
        $post_id = $request->get_param( 'id' );
        $post = get_post( $post_id );
        if ( !$post ) {
            return new WP_REST_Response([
                'error' => 'Post not found.',
            ], 404);
        }
        $text = self::extract_text( $post );
        if ( empty( trim( $text ) ) ) {
            return new WP_REST_Response([
                'post_id' => $post_id,
                'message' => 'No text content found.',
                'errors'  => [],
            ], 200);
        }
        // Phase 1 stub; Phase 2 browser scan replaces this for full-site scans.
        $errors = Spellmatic_Harper::check( $text, $post_id );
        $scan_id = self::store_results( $post_id, $post->post_type, $errors );
        return new WP_REST_Response([
            'post_id' => $post_id,
            'scan_id' => $scan_id,
            'title'   => get_the_title( $post_id ),
            'errors'  => $errors,
            'count'   => count( $errors ),
        ], 200);
    }

    /**
     * Get the status / results of the most recent scan.
     * REST: GET /spellmatic/v1/scan/status
     */
    public static function get_scan_status( WP_REST_Request $request ) : WP_REST_Response {
        global $wpdb;
        $scan_id = get_transient( 'spellmatic_current_scan' );
        if ( !$scan_id ) {
            return new WP_REST_Response([
                'status'  => 'idle',
                'message' => 'No scan in progress.',
            ], 200);
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; scan status must be fresh.
        $scan = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}spellmatic_scans WHERE id = %s", $scan_id ), ARRAY_A );
        if ( !$scan ) {
            return new WP_REST_Response([
                'status' => 'idle',
            ], 200);
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; results must be fresh.
        $results = $wpdb->get_results( $wpdb->prepare( "SELECT id, post_id, post_type, field, error_type, original, suggestion, context, status\n                 FROM {$wpdb->prefix}spellmatic_results\n                 WHERE scan_id = %s AND status = %s\n                 ORDER BY post_id ASC\n                 LIMIT 200", $scan_id, 'open' ), ARRAY_A );
        return new WP_REST_Response([
            'scan_id'       => $scan_id,
            'status'        => $scan['status'],
            'total_items'   => (int) $scan['total_items'],
            'scanned_items' => (int) $scan['scanned_items'],
            'error_count'   => (int) $scan['error_count'],
            'percent'       => ( $scan['total_items'] > 0 ? round( $scan['scanned_items'] / $scan['total_items'] * 100 ) : 0 ),
            'results'       => $results,
        ], 200);
    }

    // =========================================================================
    // SHARED HELPERS
    // =========================================================================
    /**
     * Extract plain text from a post.
     * Phase 3+: delegates to Spellmatic_ContentExtractor.
     * Kept for backward compatibility with single-post scan (Phase 1 endpoint).
     */
    public static function extract_text( WP_Post $post ) : string {
        $items = Spellmatic_ContentExtractor::get_items( $post );
        $parts = [];
        foreach ( $items as $item ) {
            if ( $item['field'] !== 'post_title' && !empty( $item['text'] ) ) {
                $parts[] = $item['text'];
            }
        }
        return implode( "\n\n", $parts );
    }

    /**
     * Build the list of post IDs to scan.
     *
     * @param array $settings        Plugin settings (legacy flags).
     * @param array $requested_types Optional explicit list of post type slugs from
     *                               the scope selector — overrides settings when set.
     */
    private static function get_scannable_post_ids( array $settings, array $requested_types = [] ) : array {
        if ( !empty( $requested_types ) ) {
            // Scope selector sent explicit types — use them directly.
            $post_types = array_map( 'sanitize_key', $requested_types );
        } else {
            // Default: scan all registered public post types.
            $post_types = array_keys( self::get_public_post_types() );
        }
        if ( empty( $post_types ) ) {
            return [];
        }
        $query = new WP_Query([
            'post_type'      => $post_types,
            'post_status'    => 'publish',
            'posts_per_page' => -1,
            'fields'         => 'ids',
            'no_found_rows'  => true,
        ]);
        return $query->posts;
    }

    /**
     * Store results for a single-post scan (Phase 1 compatibility).
     */
    public static function store_results( int $post_id, string $post_type, array $errors ) : string {
        global $wpdb;
        $scan_id = wp_generate_uuid4();
        foreach ( $errors as $error ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table insert for scan results.
            $wpdb->insert( $wpdb->prefix . 'spellmatic_results', [
                'post_id'      => $post_id,
                'post_type'    => sanitize_key( $post_type ),
                'field'        => sanitize_key( $error['field'] ?? 'post_content' ),
                'error_type'   => sanitize_key( $error['type'] ?? 'spelling' ),
                'original'     => sanitize_text_field( $error['original'] ?? '' ),
                'suggestion'   => sanitize_text_field( $error['suggestion'] ?? '' ),
                'context'      => sanitize_text_field( $error['context'] ?? '' ),
                'offset_start' => (int) ($error['offset_start'] ?? 0),
                'offset_end'   => (int) ($error['offset_end'] ?? 0),
                'status'       => 'open',
                'scan_id'      => $scan_id,
            ] );
        }
        return $scan_id;
    }

}
