<?php

declare(strict_types=1);

namespace SiteFort\Modules\Scanner\FileIntegrity;

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

/**
 * BinaryContentChecker
 *
 * Local binary pre-filter for deep scans.
 */
class BinaryContentChecker {

    // Maximum bytes read from the start of the file.
    private const HEAD_BYTES = 8192; // 8 KB

    // Maximum bytes read from the end of the file.
    private const TAIL_BYTES = 4096; // 4 KB

    // Maximum bytes read from any single metadata/trailer region.
    private const REGION_BYTES = 65536; // 64 KB

    private const IMAGE_EXTENSIONS = [ 'jpg', 'jpeg', 'png', 'gif', 'ico', 'webp' ];

    private const PATTERNS = [
        '<?php',
        '<?=',
        '<? ',
        '<!doctype html',
        '<html',
        '<script',
        'javascript:',
        'eval(',
        'eval (',
        'base64_decode(',
        'gzinflate(',
        'gzuncompress(',
        'gzdeflate(',
        'str_rot13(',
        'assert(',
        'preg_replace(',
    ];

    private const IMAGE_PAYLOAD_PATTERNS = [
        '<?php',
        '<?=',
        '<? ',
        '<!doctype html',
        '<html',
        '<script',
    ];

    public static function check( string $file_path ): bool {
        if ( ! is_readable( $file_path ) ) {
            return false;
        }

        clearstatcache( true, $file_path );
        $size = @filesize( $file_path );
        if ( $size === false || $size === 0 ) {
            return false;
        }

        $extension = strtolower( ltrim( (string) pathinfo( $file_path, PATHINFO_EXTENSION ), '.' ) );
        if ( in_array( $extension, self::IMAGE_EXTENSIONS, true ) ) {
            return self::check_image_file( $file_path, $extension, (int) $size );
        }

        $head = (string) file_get_contents( $file_path, false, null, 0, self::HEAD_BYTES );

        $tail = '';
        if ( $size > self::HEAD_BYTES + self::TAIL_BYTES ) {
            $tail_offset = max( 0, $size - self::TAIL_BYTES );
            $tail = (string) file_get_contents( $file_path, false, null, $tail_offset, self::TAIL_BYTES );
        }

        $content = $head . $tail;
        if ( $content === '' ) {
            return false;
        }

        return self::contains_pattern( $content, self::PATTERNS );
    }

    private static function check_image_file( string $file_path, string $extension, int $size ): bool {
        switch ( $extension ) {
            case 'png':
                return self::check_png_file( $file_path, $size );
            case 'jpg':
            case 'jpeg':
                return self::check_jpeg_file( $file_path, $size );
            case 'gif':
                return self::check_gif_file( $file_path, $size );
            case 'webp':
                return self::check_webp_file( $file_path, $size );
            case 'ico':
                return self::check_ico_file( $file_path, $size );
            default:
                return self::check_head_tail_regions( $file_path, $size );
        }
    }

    private static function check_png_file( string $file_path, int $size ): bool {
        $signature = (string) file_get_contents( $file_path, false, null, 0, 8 );
        if ( $signature !== "\x89PNG\x0D\x0A\x1A\x0A" ) {
            return self::check_head_tail_regions( $file_path, $size );
        }

        $offset = 8;

        while ( $offset + 12 <= $size ) {
            $chunk_header = (string) file_get_contents( $file_path, false, null, $offset, 8 );
            if ( strlen( $chunk_header ) !== 8 ) {
                return false;
            }

            $length_data = unpack( 'Nlength', substr( $chunk_header, 0, 4 ) );
            $chunk_type  = substr( $chunk_header, 4, 4 );
            $length      = isset( $length_data['length'] ) ? (int) $length_data['length'] : 0;
            $data_offset = $offset + 8;
            $next_offset = $data_offset + $length + 4;

            if ( $length < 0 || $next_offset > $size ) {
                return self::check_head_tail_regions( $file_path, $size );
            }

            if ( $length > 0 && self::is_png_text_payload_suspicious( $file_path, $chunk_type, $data_offset, $length ) ) {
                return true;
            }

            if ( $chunk_type === 'IEND' ) {
                $trailer_length = $size - $next_offset;
                if ( $trailer_length > 0 ) {
                    $trailer = (string) file_get_contents( $file_path, false, null, $next_offset, min( $trailer_length, self::REGION_BYTES ) );
                    return self::contains_image_payload_pattern( $trailer );
                }
                return false;
            }

            $offset = $next_offset;
        }

        return false;
    }

    private static function is_png_text_payload_suspicious( string $file_path, string $chunk_type, int $data_offset, int $length ): bool {
        if ( $chunk_type === 'tEXt' ) {
            $region = (string) file_get_contents( $file_path, false, null, $data_offset, min( $length, self::REGION_BYTES ) );
            return self::contains_image_payload_pattern( $region );
        }

        if ( $chunk_type === 'zTXt' ) {
            $region = (string) file_get_contents( $file_path, false, null, $data_offset, min( $length, self::REGION_BYTES ) );
            $parts  = explode( "\0", $region, 2 );
            if ( count( $parts ) !== 2 || strlen( $parts[1] ) < 2 ) {
                return false;
            }

            $compression_method = ord( $parts[1][0] );
            if ( $compression_method !== 0 ) {
                return false;
            }

            $text = @gzuncompress( substr( $parts[1], 1 ), self::REGION_BYTES );
            return is_string( $text ) && self::contains_image_payload_pattern( $text );
        }

        if ( $chunk_type === 'iTXt' ) {
            $region = (string) file_get_contents( $file_path, false, null, $data_offset, min( $length, self::REGION_BYTES ) );
            $keyword_end = strpos( $region, "\0" );
            if ( $keyword_end === false ) {
                return false;
            }

            $cursor = $keyword_end + 1;
            if ( $cursor + 2 > strlen( $region ) ) {
                return false;
            }

            $compression_flag   = ord( $region[ $cursor ] );
            $compression_method = ord( $region[ $cursor + 1 ] );
            $cursor += 2;

            $language_end = strpos( $region, "\0", $cursor );
            if ( $language_end === false ) {
                return false;
            }
            $cursor = $language_end + 1;

            $translated_end = strpos( $region, "\0", $cursor );
            if ( $translated_end === false ) {
                return false;
            }

            $text = substr( $region, $translated_end + 1 );
            if ( $compression_flag === 1 ) {
                if ( $compression_method !== 0 ) {
                    return false;
                }
                $inflated = @gzuncompress( $text, self::REGION_BYTES );
                $text     = is_string( $inflated ) ? $inflated : '';
            } elseif ( $compression_flag !== 0 ) {
                return false;
            }

            return self::contains_image_payload_pattern( $text );
        }

        return false;
    }

    private static function check_jpeg_file( string $file_path, int $size ): bool {
        $signature = (string) file_get_contents( $file_path, false, null, 0, 2 );
        if ( $signature !== "\xFF\xD8" ) {
            return self::check_head_tail_regions( $file_path, $size );
        }

        $offset = 2;
        while ( $offset + 4 <= $size ) {
            $marker_prefix = (string) file_get_contents( $file_path, false, null, $offset, 1 );
            if ( $marker_prefix !== "\xFF" ) {
                $eoi = self::find_bytes_from( $file_path, "\xFF\xD9", $offset, $size );
                if ( $eoi === null ) {
                    return false;
                }
                return self::check_trailer( $file_path, $size, $eoi + 2 );
            }

            do {
                $offset++;
                $marker = (string) file_get_contents( $file_path, false, null, $offset, 1 );
            } while ( $marker === "\xFF" && $offset + 1 < $size );

            if ( $marker === '' ) {
                return false;
            }

            $marker_code = ord( $marker );
            $offset++;

            if ( $marker_code === 0xD9 ) {
                return self::check_trailer( $file_path, $size, $offset );
            }
            if ( $marker_code === 0xDA ) {
                $eoi = self::find_bytes_from( $file_path, "\xFF\xD9", $offset, $size );
                if ( $eoi === null ) {
                    return false;
                }
                return self::check_trailer( $file_path, $size, $eoi + 2 );
            }
            if ( $marker_code >= 0xD0 && $marker_code <= 0xD7 ) {
                continue;
            }

            $length_bytes = (string) file_get_contents( $file_path, false, null, $offset, 2 );
            if ( strlen( $length_bytes ) !== 2 ) {
                return false;
            }

            $length_data = unpack( 'nlength', $length_bytes );
            $length      = isset( $length_data['length'] ) ? (int) $length_data['length'] : 0;
            if ( $length < 2 || $offset + $length > $size ) {
                return self::check_head_tail_regions( $file_path, $size );
            }

            $segment_offset = $offset + 2;
            $segment_length = $length - 2;
            if ( $segment_length > 0 && self::is_jpeg_text_segment( $marker_code, $file_path, $segment_offset, $segment_length ) ) {
                $segment = (string) file_get_contents( $file_path, false, null, $segment_offset, min( $segment_length, self::REGION_BYTES ) );
                if ( self::contains_image_payload_pattern( $segment ) ) {
                    return true;
                }
            }

            $offset += $length;
        }

        return false;
    }

    private static function is_jpeg_text_segment( int $marker_code, string $file_path, int $segment_offset, int $segment_length ): bool {
        if ( $marker_code === 0xFE ) {
            return true;
        }

        if ( $marker_code !== 0xE1 ) {
            return false;
        }

        $prefix = (string) file_get_contents( $file_path, false, null, $segment_offset, min( $segment_length, 64 ) );
        return strncmp( $prefix, "http://ns.adobe.com/xap/1.0/\0", 29 ) === 0;
    }

    private static function check_gif_file( string $file_path, int $size ): bool {
        $header = (string) file_get_contents( $file_path, false, null, 0, 13 );
        if ( strlen( $header ) !== 13 || ( strncmp( $header, 'GIF87a', 6 ) !== 0 && strncmp( $header, 'GIF89a', 6 ) !== 0 ) ) {
            return self::check_head_tail_regions( $file_path, $size );
        }

        $packed = ord( $header[10] );
        $offset = 13;
        if ( ( $packed & 0x80 ) !== 0 ) {
            $offset += 3 * ( 2 ** ( ( $packed & 0x07 ) + 1 ) );
        }

        while ( $offset < $size ) {
            $block = (string) file_get_contents( $file_path, false, null, $offset, 1 );
            if ( $block === '' ) {
                return false;
            }

            $block_code = ord( $block );
            $offset++;

            if ( $block_code === 0x3B ) {
                return self::check_trailer( $file_path, $size, $offset );
            }

            if ( $block_code === 0x21 ) {
                $label = (string) file_get_contents( $file_path, false, null, $offset, 1 );
                if ( $label === '' ) {
                    return false;
                }
                $offset++;

                $scan_payload = ord( $label ) === 0xFE || ord( $label ) === 0x01;
                $subblocks    = self::read_gif_subblocks( $file_path, $size, $offset, $scan_payload );
                if ( $subblocks === null ) {
                    return false;
                }
                if ( $subblocks['suspicious'] ) {
                    return true;
                }
                $offset = $subblocks['offset'];
                continue;
            }

            if ( $block_code === 0x2C ) {
                $descriptor = (string) file_get_contents( $file_path, false, null, $offset, 9 );
                if ( strlen( $descriptor ) !== 9 ) {
                    return false;
                }
                $offset += 9;

                $image_packed = ord( $descriptor[8] );
                if ( ( $image_packed & 0x80 ) !== 0 ) {
                    $offset += 3 * ( 2 ** ( ( $image_packed & 0x07 ) + 1 ) );
                }

                if ( $offset >= $size ) {
                    return false;
                }
                $offset++;

                $subblocks = self::read_gif_subblocks( $file_path, $size, $offset, false );
                if ( $subblocks === null ) {
                    return false;
                }
                $offset = $subblocks['offset'];
                continue;
            }

            return self::check_head_tail_regions( $file_path, $size );
        }

        return false;
    }

    private static function check_webp_file( string $file_path, int $size ): bool {
        $header = (string) file_get_contents( $file_path, false, null, 0, 12 );
        if ( strlen( $header ) !== 12 || strncmp( $header, 'RIFF', 4 ) !== 0 || substr( $header, 8, 4 ) !== 'WEBP' ) {
            return self::check_head_tail_regions( $file_path, $size );
        }

        $riff_length_data = unpack( 'Vlength', substr( $header, 4, 4 ) );
        $riff_end         = isset( $riff_length_data['length'] ) ? min( $size, 8 + (int) $riff_length_data['length'] ) : $size;
        $offset           = 12;
        $metadata_chunks  = [ 'XMP ' => true ];

        while ( $offset + 8 <= $riff_end ) {
            $chunk_header = (string) file_get_contents( $file_path, false, null, $offset, 8 );
            if ( strlen( $chunk_header ) !== 8 ) {
                return false;
            }

            $chunk_type = substr( $chunk_header, 0, 4 );
            $length_data = unpack( 'Vlength', substr( $chunk_header, 4, 4 ) );
            $length      = isset( $length_data['length'] ) ? (int) $length_data['length'] : 0;
            $data_offset = $offset + 8;
            $next_offset = $data_offset + $length + ( $length % 2 );

            if ( $length < 0 || $next_offset > $size ) {
                return self::check_head_tail_regions( $file_path, $size );
            }

            if ( isset( $metadata_chunks[ $chunk_type ] ) && $length > 0 ) {
                $region = (string) file_get_contents( $file_path, false, null, $data_offset, min( $length, self::REGION_BYTES ) );
                if ( self::contains_image_payload_pattern( $region ) ) {
                    return true;
                }
            }

            $offset = $next_offset;
        }

        return self::check_trailer( $file_path, $size, $riff_end );
    }

    private static function check_ico_file( string $file_path, int $size ): bool {
        $header = (string) file_get_contents( $file_path, false, null, 0, 6 );
        if ( strlen( $header ) !== 6 || substr( $header, 0, 4 ) !== "\x00\x00\x01\x00" ) {
            return self::check_head_tail_regions( $file_path, $size );
        }

        $count_data = unpack( 'vcount', substr( $header, 4, 2 ) );
        $count      = isset( $count_data['count'] ) ? (int) $count_data['count'] : 0;
        if ( $count <= 0 || $count > 256 ) {
            return self::check_head_tail_regions( $file_path, $size );
        }

        $directory_size = 6 + ( $count * 16 );
        if ( $directory_size > $size ) {
            return self::check_head_tail_regions( $file_path, $size );
        }

        $max_end = $directory_size;
        for ( $i = 0; $i < $count; $i++ ) {
            $entry = (string) file_get_contents( $file_path, false, null, 6 + ( $i * 16 ), 16 );
            if ( strlen( $entry ) !== 16 ) {
                return false;
            }

            $size_data   = unpack( 'Vsize', substr( $entry, 8, 4 ) );
            $offset_data = unpack( 'Voffset', substr( $entry, 12, 4 ) );
            $entry_size  = isset( $size_data['size'] ) ? (int) $size_data['size'] : 0;
            $entry_start = isset( $offset_data['offset'] ) ? (int) $offset_data['offset'] : 0;
            $entry_end   = $entry_start + $entry_size;
            if ( $entry_size <= 0 || $entry_start < $directory_size || $entry_end > $size ) {
                return self::check_head_tail_regions( $file_path, $size );
            }

            $max_end = max( $max_end, $entry_end );
        }

        return self::check_trailer( $file_path, $size, $max_end );
    }

    private static function check_head_tail_regions( string $file_path, int $size ): bool {
        $head = (string) file_get_contents( $file_path, false, null, 0, self::HEAD_BYTES );

        $tail = '';
        if ( $size > self::HEAD_BYTES + self::TAIL_BYTES ) {
            $tail_offset = max( 0, $size - self::TAIL_BYTES );
            $tail = (string) file_get_contents( $file_path, false, null, $tail_offset, self::TAIL_BYTES );
        }

        return self::contains_image_payload_pattern( $head . $tail );
    }

    private static function check_trailer( string $file_path, int $size, int $trailer_offset ): bool {
        if ( $trailer_offset >= $size ) {
            return false;
        }

        $trailer_length = min( $size - $trailer_offset, self::REGION_BYTES );
        $trailer        = (string) file_get_contents( $file_path, false, null, $trailer_offset, $trailer_length );

        return self::contains_image_payload_pattern( $trailer );
    }

    private static function contains_image_payload_pattern( string $content ): bool {
        return self::contains_pattern( $content, self::IMAGE_PAYLOAD_PATTERNS );
    }

    /**
     * @param array<int,string> $patterns
     */
    private static function contains_pattern( string $content, array $patterns ): bool {
        if ( $content === '' ) {
            return false;
        }

        foreach ( $patterns as $pattern ) {
            if ( stripos( $content, $pattern ) !== false ) {
                return true;
            }
        }

        return false;
    }

    private static function find_bytes_from( string $file_path, string $needle, int $offset, int $size ): ?int {
        $chunk_size = self::REGION_BYTES;
        $carry      = '';

        while ( $offset < $size ) {
            $chunk = (string) file_get_contents( $file_path, false, null, $offset, min( $chunk_size, $size - $offset ) );
            if ( $chunk === '' ) {
                return null;
            }

            $haystack = $carry . $chunk;
            $position = strpos( $haystack, $needle );
            if ( $position !== false ) {
                return $offset - strlen( $carry ) + $position;
            }

            $carry  = substr( $haystack, -1 );
            $offset += strlen( $chunk );
        }

        return null;
    }

    /**
     * @return array{offset:int,suspicious:bool}|null
     */
    private static function read_gif_subblocks( string $file_path, int $size, int $offset, bool $scan_payload ): ?array {
        $scanned    = 0;
        $suspicious = false;

        while ( $offset < $size ) {
            $length_byte = (string) file_get_contents( $file_path, false, null, $offset, 1 );
            if ( $length_byte === '' ) {
                return null;
            }
            $offset++;

            $length = ord( $length_byte );
            if ( $length === 0 ) {
                return [
                    'offset'     => $offset,
                    'suspicious' => $suspicious,
                ];
            }
            if ( $offset + $length > $size ) {
                return null;
            }

            if ( $scan_payload && $scanned < self::REGION_BYTES ) {
                $read_length = min( $length, self::REGION_BYTES - $scanned );
                $payload     = (string) file_get_contents( $file_path, false, null, $offset, $read_length );
                if ( self::contains_image_payload_pattern( $payload ) ) {
                    $suspicious = true;
                }
                $scanned += $read_length;
            }

            $offset += $length;
        }

        return null;
    }
}
