<?php
/**
 * Structure Viewer Pro — Comparator
 *
 * LCS-based diff engine for comparing file sets and individual files.
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit( 'Direct access denied.' );
}

// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- SV_ is the established plugin prefix used consistently across all plugin classes.
class SV_Pro_Comparator {

    /** Maximum file size for inline diff (5 MB) */
    const MAX_DIFF_SIZE = 5242880;

    /** Maximum line count for inline diff */
    const MAX_DIFF_LINES = 20000;

    /**
     * Similarity threshold (0-100): del+add pairs below this are NOT grouped.
     * 40 works well for minified JS where one trailing char differs.
     */
    const SIMILARITY_THRESHOLD = 40;

    /**
     * Minimum line length (chars) to treat as minified/embedded CSS or JS.
     * Lines longer than this in a CSS/JS file are tokenized into rules instead
     * of being diffed as a single atomic line.
     */
    const MINIFIED_LINE_THRESHOLD = 500;

    // ── File-set comparison ───────────────────────────────────────

    public static function compare( $files1, $files2, $normalize1 = true, $normalize2 = true ) {
        SV_Pro_File_Handler::increase_limits();

        $f1 = SV_Pro_File_Handler::normalize_paths( $files1, $normalize1 );
        $f2 = SV_Pro_File_Handler::normalize_paths( $files2, $normalize2 );

        $result = [
            'added'     => [],
            'removed'   => [],
            'modified'  => [],
            'unchanged' => [],
            'stats'     => [
                'files1'        => count( $f1 ),
                'files2'        => count( $f2 ),
                'total_added'   => 0,
                'total_removed' => 0,
            ],
        ];

        // New and modified files
        $counter = 0;
        foreach ( $f2 as $path => $info ) {
            if ( ++$counter % 50 === 0 ) {
                @set_time_limit( 60 ); // phpcs:ignore
            }

            if ( ! isset( $f1[ $path ] ) ) {
                $is_text           = SV_Pro_File_Handler::is_text_file( $path );
                $result['added'][] = [
                    'file'     => $path,
                    'size'     => $info['size'],
                    'is_text'  => $is_text,
                    'can_view' => $is_text && $info['size'] < self::MAX_DIFF_SIZE,
                    'path'     => $info['path'],
                ];
            } elseif ( $f1[ $path ]['hash'] !== $info['hash'] ) {
                $is_text              = SV_Pro_File_Handler::is_text_file( $path );
                $can_diff             = $is_text
                    && $f1[ $path ]['size'] < self::MAX_DIFF_SIZE
                    && $info['size'] < self::MAX_DIFF_SIZE;
                $result['modified'][] = [
                    'file'     => $path,
                    'old_size' => $f1[ $path ]['size'],
                    'new_size' => $info['size'],
                    'is_text'  => $is_text,
                    'can_diff' => $can_diff,
                    'old_path' => $f1[ $path ]['path'],
                    'new_path' => $info['path'],
                ];
            } else {
                $result['unchanged'][] = [
                    'file' => $path,
                    'size' => $info['size'],
                ];
            }
        }

        // Removed files
        foreach ( $f1 as $path => $info ) {
            if ( ! isset( $f2[ $path ] ) ) {
                $is_text             = SV_Pro_File_Handler::is_text_file( $path );
                $result['removed'][] = [
                    'file'     => $path,
                    'size'     => $info['size'],
                    'is_text'  => $is_text,
                    'can_view' => $is_text && $info['size'] < self::MAX_DIFF_SIZE,
                    'path'     => $info['path'],
                ];
            }
        }

        return $result;
    }

    // ── Diff between two files ────────────────────────────────────

    public static function get_diff( $old_path, $new_path ) {
        SV_Pro_File_Handler::increase_limits();

        $old_lines = @file( $old_path, FILE_IGNORE_NEW_LINES ); // phpcs:ignore
        $new_lines = @file( $new_path, FILE_IGNORE_NEW_LINES ); // phpcs:ignore

        if ( $old_lines === false || $new_lines === false ) {
            return [ 'error' => 'Cannot read files' ];
        }

        $old_count = count( $old_lines );
        $new_count = count( $new_lines );

        if ( $old_count > self::MAX_DIFF_LINES || $new_count > self::MAX_DIFF_LINES ) {
            return [
                'too_large' => true,
                'old_lines' => $old_count,
                'new_lines' => $new_count,
                'message'   => 'File too large for detailed diff',
            ];
        }

        // Detect minified/embedded CSS or JS: if the file contains very long
        // lines (e.g. a single-line minified stylesheet or script), expand each
        // long line into its constituent CSS rules / JS statements so the diff
        // shows meaningful token-level changes instead of the whole line as
        // one huge add/remove pair.
        $ext          = strtolower( pathinfo( $old_path, PATHINFO_EXTENSION ) );
        $is_css_or_js = in_array( $ext, [ 'css', 'js', 'jsx', 'ts', 'tsx' ], true );

        // Detect minification BEFORE expansion — after expand_minified_lines()
        // all lines are short by design, so the check would always miss.
        $had_minified_source = $is_css_or_js
            && self::has_minified_lines( array_merge( $old_lines, $new_lines ) );

        if ( $is_css_or_js ) {
            $old_lines = self::expand_minified_lines( $old_lines, $ext );
            $new_lines = self::expand_minified_lines( $new_lines, $ext );
        }

        $diff   = self::compute_diff( $old_lines, $new_lines );
        $result = self::pair_similar_changes( $diff );

        // Tell the front-end this diff came from a minified source so it can
        // display a note and apply character-level highlighting.
        if ( $had_minified_source ) {
            $result['is_minified'] = true;
        }

        return $result;
    }

    // ── Single file comparison ────────────────────────────────────

    public static function compare_single_files( $file1_path, $file2_path, $file1_name, $file2_name ) {
        SV_Pro_File_Handler::increase_limits();

        if ( ! file_exists( $file1_path ) || ! file_exists( $file2_path ) ) {
            return [ 'error' => 'One or both files not found' ];
        }

        $hash1 = md5_file( $file1_path );
        $hash2 = md5_file( $file2_path );
        $size1 = filesize( $file1_path );
        $size2 = filesize( $file2_path );

        $result = [
            'file1'     => [ 'name' => $file1_name, 'size' => $size1 ],
            'file2'     => [ 'name' => $file2_name, 'size' => $size2 ],
            'identical' => ( $hash1 === $hash2 ),
            'can_diff'  => false,
            'diff'      => null,
        ];

        if ( $result['identical'] ) {
            return $result;
        }

        if (
            SV_Pro_File_Handler::is_text_file( $file1_path ) &&
            SV_Pro_File_Handler::is_text_file( $file2_path ) &&
            $size1 < self::MAX_DIFF_SIZE &&
            $size2 < self::MAX_DIFF_SIZE
        ) {
            $result['can_diff'] = true;
            $result['diff']     = self::get_diff( $file1_path, $file2_path );
        }

        return $result;
    }

    // ── File content for added/removed display ────────────────────

    public static function get_file_content( $file_path, $type = 'added' ) {
        SV_Pro_File_Handler::increase_limits();

        if ( ! file_exists( $file_path ) ) {
            return [ 'error' => 'File not found' ];
        }

        // Defence in depth: only allow reads from expected locations
        // (the Comparator's upload staging area, installed plugins, installed themes).
        $real = realpath( $file_path );
        if ( ! $real ) {
            return [ 'error' => 'File not found' ];
        }
        $allowed = array_filter( [
            realpath( SV_Pro_File_Handler::get_upload_dir() ),
            realpath( WP_PLUGIN_DIR ),
            realpath( get_theme_root() ),
        ] );
        $inside = false;
        foreach ( $allowed as $base ) {
            if ( $base && strpos( $real, $base . DIRECTORY_SEPARATOR ) === 0 ) {
                $inside = true;
                break;
            }
        }
        if ( ! $inside ) {
            return [ 'error' => 'Access denied' ];
        }

        $lines = @file( $real, FILE_IGNORE_NEW_LINES ); // phpcs:ignore
        if ( $lines === false ) {
            return [ 'error' => 'Cannot read file' ];
        }

        $line_count = count( $lines );
        if ( $line_count > self::MAX_DIFF_LINES ) {
            return [
                'too_large'   => true,
                'lines_count' => $line_count,
                'message'     => 'File too large to display (' . number_format( $line_count ) . ' lines)',
            ];
        }

        $result = [ 'lines' => [], 'total_lines' => $line_count, 'type' => $type ];
        foreach ( $lines as $num => $content ) {
            $result['lines'][] = [ 'num' => $num + 1, 'content' => $content ];
        }
        return $result;
    }

    // ── Internal: minified file expansion ────────────────────────

    /**
     * Walk through the lines of a CSS or JS file. Any line longer than
     * MINIFIED_LINE_THRESHOLD is split into logical tokens (CSS rules or JS
     * statements) so downstream diffing works at the rule level.
     */
    private static function expand_minified_lines( $lines, $ext ) {
        $result = [];
        foreach ( $lines as $line ) {
            if ( strlen( $line ) > self::MINIFIED_LINE_THRESHOLD ) {
                $tokens = ( $ext === 'css' ) ? self::tokenize_css( $line ) : self::tokenize_js( $line );
                foreach ( $tokens as $token ) {
                    $t = trim( $token );
                    if ( $t !== '' ) {
                        $result[] = $t;
                    }
                }
            } else {
                $result[] = $line;
            }
        }
        return $result;
    }

    /**
     * Split a minified CSS string into individual rule blocks.
     * Strategy: split on `}` keeping the delimiter, then re-attach any
     * leading `@rule {` opener to its matching block.
     */
    private static function tokenize_css( $css ) {
        $raw    = preg_split( '/(?<=\})/', $css, -1, PREG_SPLIT_NO_EMPTY );
        $tokens = [];
        $buffer = '';
        foreach ( $raw as $chunk ) {
            $chunk = trim( $chunk );
            if ( $chunk === '' ) {
                continue;
            }
            $buffer .= ( $buffer !== '' ? ' ' : '' ) . $chunk;
            if ( substr_count( $buffer, '{' ) <= substr_count( $buffer, '}' ) ) {
                $tokens[] = $buffer;
                $buffer   = '';
            }
        }
        if ( $buffer !== '' ) {
            $tokens[] = $buffer;
        }

        // Split out leading comment headers (e.g. /*! plugin - v1.0 */)
        $final = [];
        foreach ( $tokens as $tok ) {
            if ( preg_match( '/^(\/\*.*?\*\/)\s*(.+)$/s', $tok, $m ) ) {
                $final[] = $m[1];
                $final[] = $m[2];
            } else {
                $final[] = $tok;
            }
        }

        return $final;
    }

    /**
     * Split a minified JS string into rough statement-level tokens.
     * We split on `;` and `}` (keeping delimiters) as a practical heuristic.
     */
    private static function tokenize_js( $js ) {
        $raw    = preg_split( '/(?<=[;}])/', $js, -1, PREG_SPLIT_NO_EMPTY );
        $tokens = [];
        $buffer = '';
        foreach ( $raw as $chunk ) {
            $buffer .= $chunk;
            $opens   = substr_count( $buffer, '{' );
            $closes  = substr_count( $buffer, '}' );
            if ( $opens <= $closes || substr( $chunk, -1 ) === ';' ) {
                $t = trim( $buffer );
                if ( $t !== '' ) {
                    $tokens[] = $t;
                }
                $buffer = '';
            }
        }
        if ( trim( $buffer ) !== '' ) {
            $tokens[] = trim( $buffer );
        }
        return $tokens;
    }

    /**
     * Check whether any lines in the set look like they came from a minified
     * source (i.e. contain at least one line longer than MINIFIED_LINE_THRESHOLD).
     * Callers must pass PRE-expansion lines — after expand_minified_lines()
     * every line has been split into tokens and will be below threshold.
     */
    private static function has_minified_lines( $lines ) {
        foreach ( $lines as $line ) {
            if ( strlen( $line ) > self::MINIFIED_LINE_THRESHOLD ) {
                return true;
            }
        }
        return false;
    }

    // ── Internal: diff engine ─────────────────────────────────────

    private static function compute_diff( $old_lines, $new_lines ) {
        $old_count  = count( $old_lines );
        $new_count  = count( $new_lines );
        $old_hashes = array_map( 'md5', $old_lines );
        $new_hashes = array_map( 'md5', $new_lines );

        if ( $old_count * $new_count <= 250000 ) {
            return self::compute_diff_lcs( $old_lines, $new_lines, $old_hashes, $new_hashes );
        }

        return self::compute_diff_patience( $old_lines, $new_lines, $old_hashes, $new_hashes );
    }

    /**
     * Exact LCS diff — used for smaller files (product <= 250 000).
     */
    private static function compute_diff_lcs( $old_lines, $new_lines, $old_hashes, $new_hashes ) {
        $result    = [ 'lines' => [], 'added' => 0, 'removed' => 0 ];
        $old_count = count( $old_lines );
        $new_count = count( $new_lines );

        // Build LCS DP table
        $dp = [];
        for ( $i = 0; $i <= $old_count; $i++ ) {
            $dp[ $i ] = array_fill( 0, $new_count + 1, 0 );
        }
        for ( $i = 1; $i <= $old_count; $i++ ) {
            for ( $j = 1; $j <= $new_count; $j++ ) {
                $dp[ $i ][ $j ] = ( $old_hashes[ $i - 1 ] === $new_hashes[ $j - 1 ] )
                    ? $dp[ $i - 1 ][ $j - 1 ] + 1
                    : max( $dp[ $i - 1 ][ $j ], $dp[ $i ][ $j - 1 ] );
            }
        }

        // Backtrack to find matching pairs
        $matches = [];
        $i       = $old_count;
        $j       = $new_count;
        while ( $i > 0 && $j > 0 ) {
            if ( $old_hashes[ $i - 1 ] === $new_hashes[ $j - 1 ] ) {
                $matches[ $i - 1 ] = $j - 1;
                $i--;
                $j--;
            } elseif ( $dp[ $i - 1 ][ $j ] >= $dp[ $i ][ $j - 1 ] ) {
                $i--;
            } else {
                $j--;
            }
        }

        ksort( $matches );
        return self::emit_diff_lines( $old_lines, $new_lines, $matches, $result );
    }

    /**
     * Patience-anchored diff — used for large files (product > 250 000).
     *
     * Pass 1: Lines that appear EXACTLY ONCE in both files are perfect anchors
     *         (patience diff principle). These are locked in first.
     * Pass 2: Within each gap between anchors, run a forward greedy match for
     *         the remaining lines.
     *
     * This finds far more matching lines than the old single-pass greedy
     * approach, which is why the old version reported wildly inflated counts
     * on large minified JS files.
     */
    private static function compute_diff_patience( $old_lines, $new_lines, $old_hashes, $new_hashes ) {
        $result    = [ 'lines' => [], 'added' => 0, 'removed' => 0 ];
        $old_count = count( $old_lines );
        $new_count = count( $new_lines );

        $old_freq = array_count_values( $old_hashes );
        $new_freq = array_count_values( $new_hashes );

        // Build position index for new file
        $new_pos = [];
        foreach ( $new_hashes as $j => $h ) {
            $new_pos[ $h ][] = $j;
        }

        $old_matched = []; // old_idx => new_idx
        $new_matched = []; // new_idx => true

        // --- Pass 1: unique anchors ---
        $last_new = -1;
        foreach ( $old_hashes as $i => $h ) {
            if ( $old_freq[ $h ] === 1 && isset( $new_freq[ $h ] ) && $new_freq[ $h ] === 1 ) {
                $j = $new_pos[ $h ][0];
                if ( $j > $last_new ) {
                    $old_matched[ $i ] = $j;
                    $new_matched[ $j ] = true;
                    $last_new          = $j;
                }
            }
        }

        // --- Pass 2: fill gaps between anchors ---
        ksort( $old_matched );
        $anchors  = array_keys( $old_matched );
        $prev_old = 0;
        $prev_new = 0;
        $segments = [];

        foreach ( $anchors as $ai ) {
            $aj         = $old_matched[ $ai ];
            $segments[] = [ $prev_old, $ai - 1, $prev_new, $aj - 1 ];
            $prev_old   = $ai + 1;
            $prev_new   = $aj + 1;
        }
        $segments[] = [ $prev_old, $old_count - 1, $prev_new, $new_count - 1 ];

        foreach ( $segments as $seg ) {
            list( $os, $oe, $ns, $ne ) = $seg;
            if ( $os > $oe || $ns > $ne ) {
                continue;
            }

            // Local index for unmatched new lines in this segment
            $seg_idx = [];
            for ( $j = $ns; $j <= $ne; $j++ ) {
                if ( ! isset( $new_matched[ $j ] ) ) {
                    $seg_idx[ $new_hashes[ $j ] ][] = $j;
                }
            }

            $seg_last = $ns - 1;
            for ( $i = $os; $i <= $oe; $i++ ) {
                if ( isset( $old_matched[ $i ] ) ) {
                    continue;
                }
                $h = $old_hashes[ $i ];
                if ( ! isset( $seg_idx[ $h ] ) ) {
                    continue;
                }
                foreach ( $seg_idx[ $h ] as $j ) {
                    if ( $j > $seg_last && ! isset( $new_matched[ $j ] ) ) {
                        $old_matched[ $i ] = $j;
                        $new_matched[ $j ] = true;
                        $seg_last          = $j;
                        break;
                    }
                }
            }
        }

        ksort( $old_matched );
        return self::emit_diff_lines( $old_lines, $new_lines, $old_matched, $result );
    }

    /**
     * Walk both files using the match map and emit typed diff lines.
     *
     * Priority order (fixes out-of-order line numbers on large files):
     *   1. Both pointers sit exactly on their next matched pair -> emit 'same'.
     *   2. Old pointer has NOT reached its next match yet -> emit 'del' first.
     *      This guarantees all removals before the next anchor are flushed
     *      before we start emitting additions, which prevents old line numbers
     *      from appearing far out of order in the rendered diff.
     *   3. New pointer has NOT reached its next match yet -> emit 'add'.
     *   4. Safety fallback — should never be reached with a valid match map.
     *
     * The previous code had steps 2 and 3 swapped, causing the walker to
     * drain new-file additions first. When a large block was removed near the
     * top of the old file (e.g. old lines 5-84) and the new file inserted
     * different content at the same position, the old dels were deferred until
     * after thousands of new-file 'add' lines had been emitted, making old
     * line 9 appear after new line 9024 in the output.
     */
    private static function emit_diff_lines( $old_lines, $new_lines, $matches, $result ) {
        $old_count   = count( $old_lines );
        $new_count   = count( $new_lines );
        $match_keys  = array_keys( $matches );
        $match_pos   = 0;
        $match_total = count( $match_keys );
        $old_idx     = 0;
        $new_idx     = 0;

        while ( $old_idx < $old_count || $new_idx < $new_count ) {
            // Next anchor positions ($old_count/$new_count sentinels mean no more anchors)
            $cur_old = ( $match_pos < $match_total ) ? $match_keys[ $match_pos ] : $old_count;
            $cur_new = ( $match_pos < $match_total ) ? $matches[ $match_keys[ $match_pos ] ] : $new_count;

            if ( $old_idx === $cur_old && $new_idx === $cur_new ) {
                // 1. Both sides have arrived at the next matched line -> same
                $result['lines'][] = [
                    'type'    => 'same',
                    'old_num' => $old_idx + 1,
                    'new_num' => $new_idx + 1,
                    'content' => $old_lines[ $old_idx ],
                ];
                $old_idx++;
                $new_idx++;
                $match_pos++;

            } elseif ( $old_idx < $cur_old && $old_idx < $old_count ) {
                // 2. Old side has unmatched lines before the next anchor -> del first
                $result['lines'][] = [
                    'type'    => 'del',
                    'old_num' => $old_idx + 1,
                    'new_num' => null,
                    'content' => $old_lines[ $old_idx ],
                ];
                $result['removed']++;
                $old_idx++;

            } elseif ( $new_idx < $cur_new && $new_idx < $new_count ) {
                // 3. New side has unmatched lines before the next anchor -> add
                $result['lines'][] = [
                    'type'    => 'add',
                    'old_num' => null,
                    'new_num' => $new_idx + 1,
                    'content' => $new_lines[ $new_idx ],
                ];
                $result['added']++;
                $new_idx++;

            } else {
                // 4. Safety: advance past a stale pointer (should not happen)
                if ( $old_idx < $old_count ) {
                    $old_idx++;
                } elseif ( $new_idx < $new_count ) {
                    $new_idx++;
                } else {
                    break;
                }
            }
        }

        return $result;
    }

    // ── Internal: post-processing ─────────────────────────────────

    /**
     * Scan consecutive add/del blocks in the diff output. Within each block:
     *   - Pair each del with its most similar add (if >= SIMILARITY_THRESHOLD).
     *   - Output order: unpaired dels → paired (del then add) → unpaired adds.
     *
     * This fixes two issues in one pass:
     *   1. Lines differing by a tiny change (e.g. trailing colon) are shown
     *      adjacent instead of floating apart across hundreds of lines.
     *   2. Display sequence matches professional diff tools (del on left,
     *      matching add on right, interleaved with context).
     */
    private static function pair_similar_changes( $result ) {
        $in    = $result['lines'];
        $out   = [];
        $count = count( $in );
        $i     = 0;

        while ( $i < $count ) {
            if ( $in[ $i ]['type'] === 'same' ) {
                $out[] = $in[ $i++ ];
                continue;
            }

            // Gather a contiguous block of add/del lines
            $dels = [];
            $adds = [];
            while ( $i < $count && $in[ $i ]['type'] !== 'same' ) {
                if ( $in[ $i ]['type'] === 'del' ) {
                    $dels[] = $in[ $i ];
                } else {
                    $adds[] = $in[ $i ];
                }
                $i++;
            }

            if ( empty( $dels ) || empty( $adds ) ) {
                foreach ( $dels as $d ) {
                    $out[] = $d;
                }
                foreach ( $adds as $a ) {
                    $out[] = $a;
                }
                continue;
            }

            // Greedy best-match pairing (capped at 200 each side to stay fast)
            $del_count  = min( count( $dels ), 200 );
            $add_count  = min( count( $adds ), 200 );
            $used_adds  = [];
            $paired     = [];
            $paired_del = [];

            for ( $di = 0; $di < $del_count; $di++ ) {
                $best_pct = -1;
                $best_ai  = -1;
                for ( $ai = 0; $ai < $add_count; $ai++ ) {
                    if ( isset( $used_adds[ $ai ] ) ) {
                        continue;
                    }
                    similar_text( $dels[ $di ]['content'], $adds[ $ai ]['content'], $pct );
                    if ( $pct > $best_pct ) {
                        $best_pct = $pct;
                        $best_ai  = $ai;
                    }
                }
                if ( $best_pct >= self::SIMILARITY_THRESHOLD && $best_ai >= 0 ) {
                    $paired[]            = [ 'del' => $dels[ $di ], 'add' => $adds[ $best_ai ] ];
                    $used_adds[ $best_ai ] = true;
                    $paired_del[ $di ]   = true;
                }
            }

            $unpaired_dels = [];
            for ( $di = 0; $di < count( $dels ); $di++ ) {
                if ( ! isset( $paired_del[ $di ] ) ) {
                    $unpaired_dels[] = $dels[ $di ];
                }
            }
            $unpaired_adds = [];
            for ( $ai = 0; $ai < count( $adds ); $ai++ ) {
                if ( ! isset( $used_adds[ $ai ] ) ) {
                    $unpaired_adds[] = $adds[ $ai ];
                }
            }

            // Emit: pure removals → paired changes (del+add) → pure additions
            foreach ( $unpaired_dels as $d ) {
                $out[] = $d;
            }
            foreach ( $paired as $pair ) {
                $out[] = $pair['del'];
                $out[] = $pair['add'];
            }
            foreach ( $unpaired_adds as $a ) {
                $out[] = $a;
            }
        }

        $result['lines'] = $out;
        return $result;
    }
}