<?php

/**
 * Spellmatic_Fixer
 * Phase 1: post_content + post_title.
 * Phase 2: custom_suggestion override.
 * Phase 3: Elementor JSON fix-back (_elementor_data).
 * Phase 4: clear_results() — wipe all open results + scan sessions.
 */
defined( 'ABSPATH' ) || exit;
class Spellmatic_Fixer {
    public static function apply_fix( WP_REST_Request $request ) : WP_REST_Response {
        $accepted = (bool) $request->get_param( 'accepted' );
        $result_id = (int) $request->get_param( 'result_id' );
        $custom_suggestion = sanitize_text_field( $request->get_param( 'custom_suggestion' ) ?? '' );
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table; must read current status.
        $result = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}spellmatic_results WHERE id = %d AND status = %s", $result_id, 'open' ), ARRAY_A );
        if ( !$result ) {
            return new WP_REST_Response([
                'error' => 'Result not found or already resolved.',
            ], 404);
        }
        $already_clean = false;
        if ( $accepted ) {
            if ( $custom_suggestion !== '' ) {
                $result['suggestion'] = $custom_suggestion;
            }
            $success = self::write_fix( $result );
            if ( !$success ) {
                // The misspelled word no longer exists in the content — it was
                // already corrected (manually or by a previous fix). Mark it as
                // resolved instead of returning an error.
                $already_clean = true;
            }
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table status update.
        $wpdb->update( $wpdb->prefix . 'spellmatic_results', [
            'status' => ( $accepted ? 'fixed' : 'dismissed' ),
        ], [
            'id' => $result_id,
        ] );
        $response = [
            'result_id'     => $result_id,
            'status'        => ( $accepted ? 'fixed' : 'dismissed' ),
            'already_clean' => $already_clean,
            'post_id'       => (int) $result['post_id'],
        ];
        return new WP_REST_Response($response, 200);
    }

    public static function apply_bulk_fix( WP_REST_Request $request ) : WP_REST_Response {
        return new WP_REST_Response(null, 404);
    }

    public static function smart_bulk_fix( WP_REST_Request $request ) : WP_REST_Response {
        return new WP_REST_Response(null, 404);
    }

    /**
     * Check if a result has high confidence for auto-fix.
     * Returns TRUE when ALL of these conditions are met:
     * - suggestion is not empty
     * - error_type is 'spelling' (not 'grammar', 'style', etc.)
     * - Levenshtein distance between original and suggestion is <= 3
     */
    public static function is_high_confidence( $result ) : bool {
        // Ensure we have the suggestion field
        $suggestion = $result->suggestion ?? '';
        if ( empty( $suggestion ) ) {
            return false;
        }
        // Only apply spelling fixes, not grammar or style
        $error_type = $result->error_type ?? '';
        if ( 'spelling' !== $error_type ) {
            return false;
        }
        // Check Levenshtein distance to ensure minimal spelling differences
        $original = $result->original ?? '';
        $distance = levenshtein( strtolower( $original ), strtolower( $suggestion ) );
        return $distance <= 3;
    }

    /**
     * Clear all scan results and scan sessions from the DB.
     * Gives the user a clean slate without needing a new scan first.
     * The free-tier error cap is per-scan (not historical), so clearing
     * has no impact on tier gating.
     *
     * REST: POST /spellmatic/v1/results/clear
     *
     * Response: { cleared_results: int, cleared_scans: int }
     */
    public static function clear_results( WP_REST_Request $request ) : WP_REST_Response {
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Intentional TRUNCATE to clear custom tables.
        $cleared_results = (int) $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}spellmatic_results" );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Intentional TRUNCATE to clear custom tables.
        $cleared_scans = (int) $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}spellmatic_scans" );
        // Also clear the current-scan transient so the dashboard resets cleanly.
        delete_transient( 'spellmatic_current_scan' );
        return new WP_REST_Response([
            'cleared'         => true,
            'cleared_results' => $cleared_results,
            'cleared_scans'   => $cleared_scans,
        ], 200);
    }

    /**
     * Export all open results as a CSV download.
     * REST: GET /spellmatic/v1/results/export
     */
    public static function export_csv( WP_REST_Request $request ) : void {
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- CSV export must reflect current data.
        $results = $wpdb->get_results( "SELECT r.id, r.post_id, r.post_type, r.field, r.error_type,\n                    r.original, r.suggestion, r.context, r.status,\n                    r.created_at, p.post_title\n             FROM {$wpdb->prefix}spellmatic_results r\n             LEFT JOIN {$wpdb->posts} p ON p.ID = r.post_id\n             WHERE r.status = 'open'\n             ORDER BY r.post_id ASC, r.created_at ASC", ARRAY_A );
        $filename = 'spellmatic-errors-' . gmdate( 'Y-m-d' ) . '.csv';
        header( 'Content-Type: text/csv; charset=utf-8' );
        header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
        header( 'Pragma: no-cache' );
        header( 'Expires: 0' );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming CSV to php://output.
        $out = fopen( 'php://output', 'w' );
        // Header row.
        fputcsv( $out, [
            'ID',
            'Post ID',
            'Post Title',
            'Post Type',
            'Field',
            'Error Type',
            'Original',
            'Suggestion',
            'Context',
            'Status',
            'Found On'
        ] );
        foreach ( $results as $r ) {
            fputcsv( $out, [
                $r['id'],
                $r['post_id'],
                $r['post_title'],
                $r['post_type'],
                $r['field'],
                $r['error_type'],
                $r['original'],
                $r['suggestion'],
                $r['context'],
                $r['status'],
                $r['created_at']
            ] );
        }
        if ( is_resource( $out ) ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing php://output stream.
            fclose( $out );
        }
        exit;
    }

    /**
     * Restore a dismissed result back to open status.
     * REST: POST /spellmatic/v1/fix/restore
     */
    public static function restore_dismissed( WP_REST_Request $request ) : WP_REST_Response {
        $result_id = (int) $request->get_param( 'result_id' );
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table status update.
        $updated = $wpdb->update( $wpdb->prefix . 'spellmatic_results', [
            'status' => 'open',
        ], [
            'id'     => $result_id,
            'status' => 'dismissed',
        ] );
        if ( !$updated ) {
            return new WP_REST_Response([
                'error' => 'Result not found or not dismissed.',
            ], 404);
        }
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fresh count needed for response.
        $open_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}spellmatic_results WHERE status = 'open'" );
        return new WP_REST_Response([
            'result_id'   => $result_id,
            'status'      => 'open',
            'open_errors' => $open_count,
        ], 200);
    }

    /**
     * Revert a previously applied fix.
     * REST: POST /spellmatic/v1/fix/revert
     */
    public static function revert_fix( WP_REST_Request $request ) : WP_REST_Response {
        $result_id = (int) $request->get_param( 'result_id' );
        global $wpdb;
        // Find the most recent non-reverted history entry for this result.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table lookup; must be fresh.
        $history = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}spellmatic_fix_history\n             WHERE result_id = %d AND reverted = %d\n             ORDER BY fixed_at DESC LIMIT 1", $result_id, 0 ), ARRAY_A );
        if ( !$history ) {
            return new WP_REST_Response([
                'error' => 'No fix history found for this result.',
            ], 404);
        }
        $post_id = (int) $history['post_id'];
        $field = $history['field'];
        $original = $history['original_word'];
        // The misspelled word we want to restore.
        $replacement = $history['replacement'];
        // The correction that was applied.
        $post = get_post( $post_id );
        if ( !$post ) {
            return new WP_REST_Response([
                'error' => 'Post not found.',
            ], 404);
        }
        // Surgical revert: swap the replacement back to the original word
        // instead of restoring the entire content snapshot.
        $success = false;
        if ( 'post_content' === $field || 'post_title' === $field ) {
            $current = ( 'post_title' === $field ? $post->post_title : $post->post_content );
            $reverted = self::surgical_revert( $current, $replacement, $original );
            if ( $reverted === $current ) {
                return new WP_REST_Response([
                    'error' => 'Could not find the corrected text in the current content. It may have been manually edited.',
                ], 409);
            }
            $update = wp_update_post( [
                'ID'   => $post_id,
                $field => $reverted,
            ], true );
            $success = !is_wp_error( $update );
        } elseif ( 'elementor' === $field || str_starts_with( $field, 'elementor_' ) ) {
            $current = get_post_meta( $post_id, '_elementor_data', true );
            $reverted = self::surgical_revert( $current, $replacement, $original );
            if ( $reverted === $current ) {
                return new WP_REST_Response([
                    'error' => 'Could not find the corrected text in the current content.',
                ], 409);
            }
            update_post_meta( $post_id, '_elementor_data', wp_slash( $reverted ) );
            update_post_meta( $post_id, '_elementor_css', '' );
            $success = true;
        } elseif ( in_array( $field, ['divi', 'wpbakery', 'avada'], true ) ) {
            $current = $post->post_content;
            $reverted = self::surgical_revert( $current, $replacement, $original );
            if ( $reverted === $current ) {
                return new WP_REST_Response([
                    'error' => 'Could not find the corrected text in the current content.',
                ], 409);
            }
            $update = wp_update_post( [
                'ID'           => $post_id,
                'post_content' => $reverted,
            ], true );
            $success = !is_wp_error( $update );
        } elseif ( 'short_description' === $field ) {
            $current = $post->post_excerpt;
            $reverted = self::surgical_revert( $current, $replacement, $original );
            if ( $reverted === $current ) {
                return new WP_REST_Response([
                    'error' => 'Could not find the corrected text in the current content.',
                ], 409);
            }
            $update = wp_update_post( [
                'ID'           => $post_id,
                'post_excerpt' => $reverted,
            ], true );
            $success = !is_wp_error( $update );
        } elseif ( 'purchase_note' === $field ) {
            $current = get_post_meta( $post_id, '_purchase_note', true ) ?? '';
            $reverted = self::surgical_revert( $current, $replacement, $original );
            if ( $reverted === $current ) {
                return new WP_REST_Response([
                    'error' => 'Could not find the corrected text in the current content.',
                ], 409);
            }
            update_post_meta( $post_id, '_purchase_note', $reverted );
            $success = true;
        }
        if ( !$success ) {
            return new WP_REST_Response([
                'error' => 'Could not revert the fix.',
            ], 500);
        }
        // Mark history entry as reverted.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table update.
        $wpdb->update( $wpdb->prefix . 'spellmatic_fix_history', [
            'reverted' => 1,
        ], [
            'id' => (int) $history['id'],
        ] );
        // Re-open the result so it appears in the error list again.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table update.
        $wpdb->update( $wpdb->prefix . 'spellmatic_results', [
            'status' => 'open',
        ], [
            'id' => $result_id,
        ] );
        return new WP_REST_Response([
            'reverted'  => true,
            'result_id' => $result_id,
            'post_id'   => $post_id,
        ], 200);
    }

    private static function write_fix( array $result ) : bool {
        $post_id = (int) $result['post_id'];
        $result_id = (int) ($result['id'] ?? 0);
        $original = $result['original'];
        $suggestion = $result['suggestion'];
        $field = $result['field'];
        $context = $result['context'] ?? '';
        if ( empty( $original ) || $original === $suggestion ) {
            return false;
        }
        $post = get_post( $post_id );
        if ( !$post ) {
            return false;
        }
        if ( 'post_content' === $field || 'post_title' === $field ) {
            $content = $post->{$field};
            $updated = self::contextual_replace(
                $content,
                $original,
                $suggestion,
                $context
            );
            if ( $updated === $content ) {
                return false;
            }
            self::record_fix_history(
                $result_id,
                $post_id,
                $field,
                $original,
                $suggestion,
                $content
            );
            $update = wp_update_post( [
                'ID'   => $post_id,
                $field => $updated,
            ], true );
            return !is_wp_error( $update );
        }
        if ( 'elementor' === $field || str_starts_with( $field, 'elementor_' ) ) {
            $raw = get_post_meta( $post_id, '_elementor_data', true );
            if ( empty( $raw ) ) {
                return false;
            }
            $updated = self::contextual_replace(
                $raw,
                $original,
                $suggestion,
                $context
            );
            if ( $updated === $raw ) {
                return false;
            }
            self::record_fix_history(
                $result_id,
                $post_id,
                $field,
                $original,
                $suggestion,
                $raw
            );
            update_post_meta( $post_id, '_elementor_data', wp_slash( $updated ) );
            update_post_meta( $post_id, '_elementor_css', '' );
            return true;
        }
        // Phase 3 builders: Divi, WPBakery, Avada store content in post_content.
        if ( in_array( $field, ['divi', 'wpbakery', 'avada'], true ) ) {
            $content = $post->post_content;
            $updated = self::contextual_replace(
                $content,
                $original,
                $suggestion,
                $context
            );
            if ( $updated === $content ) {
                return false;
            }
            self::record_fix_history(
                $result_id,
                $post_id,
                $field,
                $original,
                $suggestion,
                $content
            );
            $update = wp_update_post( [
                'ID'           => $post_id,
                'post_content' => $updated,
            ], true );
            return !is_wp_error( $update );
        }
        // WooCommerce: short description (post_excerpt).
        if ( 'short_description' === $field ) {
            $content = $post->post_excerpt;
            $updated = self::contextual_replace(
                $content,
                $original,
                $suggestion,
                $context
            );
            if ( $updated === $content ) {
                return false;
            }
            self::record_fix_history(
                $result_id,
                $post_id,
                $field,
                $original,
                $suggestion,
                $content
            );
            $update = wp_update_post( [
                'ID'           => $post_id,
                'post_excerpt' => $updated,
            ], true );
            return !is_wp_error( $update );
        }
        // WooCommerce: purchase note (post meta).
        if ( 'purchase_note' === $field ) {
            $content = get_post_meta( $post_id, '_purchase_note', true ) ?? '';
            $updated = self::contextual_replace(
                $content,
                $original,
                $suggestion,
                $context
            );
            if ( $updated === $content ) {
                return false;
            }
            self::record_fix_history(
                $result_id,
                $post_id,
                $field,
                $original,
                $suggestion,
                $content
            );
            update_post_meta( $post_id, '_purchase_note', $updated );
            return true;
        }
        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional debug logging for unhandled fields.
            error_log( 'Spellmatic: write_fix — unhandled field "' . $field . '" for post ' . $post_id );
        }
        return false;
    }

    /**
     * Store a snapshot of the content before the fix is applied.
     */
    private static function record_fix_history(
        int $result_id,
        int $post_id,
        string $field,
        string $original,
        string $suggestion,
        string $before_content
    ) : void {
        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table insert for fix history tracking.
        $wpdb->insert( $wpdb->prefix . 'spellmatic_fix_history', [
            'result_id'      => $result_id,
            'post_id'        => $post_id,
            'field'          => $field,
            'original_word'  => $original,
            'replacement'    => $suggestion,
            'before_content' => $before_content,
        ] );
    }

    /**
     * Replace the $original word with $suggestion using context to pinpoint the
     * correct occurrence. This avoids the global str_replace() problem where
     * "teh" appearing 3 times would all get replaced when only one was intended.
     *
     * Strategy:
     *   1. If context is available, find the context string in the content and
     *      replace $original within that located segment only.
     *   2. Fallback: replace only the first occurrence (safe default).
     */
    private static function contextual_replace(
        string $content,
        string $original,
        string $suggestion,
        string $context
    ) : string {
        // Strategy 1: Use the context string to locate the exact occurrence.
        if ( !empty( $context ) ) {
            // The context is plain text, but the content may have HTML tags.
            // Strip tags from a copy to find the position, but apply the fix
            // on the original content using the word itself.
            // First try to find the context directly in the content.
            $ctx_pos = mb_strpos( $content, $context );
            if ( $ctx_pos !== false ) {
                // Found the context — now replace $original only within this segment.
                $ctx_len = mb_strlen( $context );
                $segment = mb_substr( $content, $ctx_pos, $ctx_len );
                $word_pos = mb_strpos( $segment, $original );
                if ( $word_pos !== false ) {
                    $abs_pos = $ctx_pos + $word_pos;
                    return mb_substr( $content, 0, $abs_pos ) . $suggestion . mb_substr( $content, $abs_pos + mb_strlen( $original ) );
                }
            }
        }
        // Strategy 2: Fallback — replace only the FIRST occurrence.
        $pos = mb_strpos( $content, $original );
        if ( $pos === false ) {
            return $content;
        }
        return mb_substr( $content, 0, $pos ) . $suggestion . mb_substr( $content, $pos + mb_strlen( $original ) );
    }

    /**
     * Surgical revert: find the first occurrence of $find (the correction
     * that was applied) in the current content and swap it back to
     * $replace_with (the original misspelled word).
     *
     * Returns the content unchanged if $find is not present, which lets
     * the caller surface a 409 "could not find" error.
     */
    private static function surgical_revert( string $content, string $find, string $replace_with ) : string {
        $pos = mb_strpos( $content, $find );
        if ( $pos === false ) {
            return $content;
        }
        return mb_substr( $content, 0, $pos ) . $replace_with . mb_substr( $content, $pos + mb_strlen( $find ) );
    }

}
