<?php

declare(strict_types=1);

namespace SiteFort\Modules\Scanner;

use SiteFort\Modules\Scanner\Service\IssueType;
use SiteFort\Modules\Scanner\Repository\IssueRepository;
use SiteFort\Modules\Scanner\Service\ScanLogger;

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

/**
 * Detects sensitive files that are publicly accessible.
 */
class SensitiveDataScanner  {

    private const CURSOR_TRANSIENT_PREFIX = 'sitefort_sensitive_scan_cursor_';
    private const DEFAULT_TIME_LIMIT = 20;

    /** @var string Current scan UUID, set by ScanController::run_scan(). */
    private $scan_id = '';

    /**
     * Get the scanner title.
     *
     * @return string
     */
    public function get_title() {
        return 'Sensitive Data Exposure';
    }

    /**
     * Bind the scanner to the current scan for cursor persistence.
     *
     * @param string|null $scan_id
     */
    public function set_scan_context( $scan_id = null ): void {
        $this->scan_id = is_string( $scan_id ) ? sanitize_text_field( $scan_id ) : '';
    }

    /**
     * Run the scan. Probes are slow, so the run is time-budgeted; a persisted
     * cursor lets the re-enqueued task resume where the last slice stopped.
     *
     * @return array{issues: array, count: int, complete: bool}
     */
    public function run() {
        if ( class_exists( ScanLogger::class ) ) {
            ScanLogger::log( "Checking for exposed sensitive files..." );
        }

        $issues     = [];
        $checks     = array_values( $this->get_checks() );
        $offset     = $this->load_cursor();
        $total      = count( $checks );
        $start_time = time();
        $time_limit = $this->get_time_limit_seconds();
        $processed  = 0;

        for ( $index = $offset; $index < $total; $index++ ) {
            // Always process at least one check per slice so the cursor advances.
            if ( $processed > 0 && ( time() - $start_time ) >= $time_limit ) {
                $this->save_cursor( $index );
                if ( class_exists( ScanLogger::class ) ) {
                    ScanLogger::log( sprintf( 'Sensitive-file check paused at %d/%d; resuming in the next slice.', $index, $total ) );
                }

                return [
                    'issues'   => $issues,
                    'count'    => count( $issues ),
                    'complete' => false,
                ];
            }

            $check = $checks[ $index ];
            $processed++;

            if ( ! file_exists( $check['path'] ) ) {
                continue;
            }

            if ( class_exists( ScanLogger::class ) ) {
                ScanLogger::log( "Found: {$check['file']} - verifying accessibility..." );
            }

            if ( $this->is_publicly_accessible( $check['url'] ) ) {
                $issues[] = $this->create_result( $check );
                if ( class_exists( ScanLogger::class ) ) {
                    ScanLogger::log( "⚠ Exposed file confirmed: {$check['file']}" );
                }
            } else {
                if ( class_exists( ScanLogger::class ) ) {
                    ScanLogger::log( "File protected (not accessible). Skipping." );
                }
            }
        }

        $this->clear_cursor();

        return [
            'issues'   => $issues,
            'count'    => count( $issues ),
            'complete' => true,
        ];
    }

    private function get_time_limit_seconds(): int {
        $max_exec = (int) ini_get( 'max_execution_time' );
        if ( $max_exec > 0 ) {
            return max( 5, min( self::DEFAULT_TIME_LIMIT, $max_exec - 5 ) );
        }

        return self::DEFAULT_TIME_LIMIT;
    }

    private function cursor_key(): string {
        return self::CURSOR_TRANSIENT_PREFIX . sanitize_key( $this->scan_id );
    }

    private function load_cursor(): int {
        if ( $this->scan_id === '' ) {
            return 0;
        }

        return max( 0, (int) get_transient( $this->cursor_key() ) );
    }

    private function save_cursor( int $offset ): void {
        if ( $this->scan_id === '' ) {
            return;
        }

        set_transient( $this->cursor_key(), max( 0, $offset ), HOUR_IN_SECONDS );
    }

    private function clear_cursor(): void {
        if ( $this->scan_id === '' ) {
            return;
        }

        delete_transient( $this->cursor_key() );
    }

    /**
     * Build a targeted list of sensitive files to check for public access.
     *
     * @return array
     */
    private function get_checks() {
        $checks = [];
        $root = trailingslashit( ABSPATH );

        $content_dir = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : ( $root . 'wp-content' );
        $content_dir = trailingslashit( $content_dir );

        // Critical config files - we check ONLY backup/exposed variants, not the legitimate files
        // wp-config.php is excluded - the legitimate file shouldn't be accessible anyway
        // Backup variants are caught dynamically via glob patterns (*.bak, *.old, *~ etc.)
        $root_files = [
            // Environment files (these should never exist or be accessible)
            '.env',
            '.env.local',
            '.env.production',
            '.env.development',
            '.env.dev',
            '.git/config',
            '.git/HEAD',
            '.git/index',
            '.svn/entries',
            '.htaccess',
            '.htpasswd',
            'composer.json',
            'composer.lock',
            'package.json',
            'yarn.lock',
            'phpinfo.php',
            'info.php',
            'adminer.php',
            'searchreplace.php',
            'error_log',
            'backup.sql',
            'database.sql',
            'dump.sql',
            'db.sql',
            'backup.zip',
            'site.zip',
            'site.tar.gz',
            // OS and editor metadata files
            '.DS_Store',
            'Thumbs.db',
            // Environment backup variants
            '.env.bak',
            // Config backup variants
            'wp-config.php.bak',
            'wp-config.old',
        ];

        foreach ( $root_files as $file ) {
            $this->add_check_path( $checks, $root . $file );
        }

        // IDE and editor directory config files
        $ide_files = [
            ".idea/workspace.xml",
            ".idea/modules.xml",
            ".vscode/settings.json",
            ".vscode/launch.json",
        ];

        foreach ( $ide_files as $file ) {
            $this->add_check_path( $checks, $root . $file );
        }

        $content_files = [
            'debug.log',
            'error_log',
        ];

        foreach ( $content_files as $file ) {
            $this->add_check_path( $checks, $content_dir . $file );
        }

        $patterns = [
            '*.sql',
            '*.sql.gz',
            '*.sql.zip',
            '*.zip',
            '*.tar',
            '*.tar.gz',
            '*.tgz',
            '*.gz',
            '*.rar',
            '*.7z',
            '*.bak',
            '*.backup',
            '*.old',
            '*~',
            '*.log',
            // Config file variants - dynamically catch wp-config.*, wp-config-*, etc.
            'wp-config.*',      // Catches wp-config.php.bak, wp-config.old, etc.
            'wp-config-*',      // Catches wp-config-backup.php, wp-config-sample.php, etc.
            '.htaccess.*',      // Catches .htaccess.bak, .htaccess.old, etc.
            // Vim swap files
            '*.swp',
        ];

        // Root and top-level wp-content only (glob is non-recursive, so
        // uploads/, plugins/, etc. are not scanned).
        $scan_dirs = [
            $root,
            $content_dir,
        ];

        $remaining = 80;
        foreach ( $scan_dirs as $dir ) {
            if ( $remaining <= 0 ) {
                break;
            }
            $remaining = $this->add_glob_checks( $checks, $dir, $patterns, $remaining );
        }

        return array_values( $checks );
    }

    private function add_check_path( array &$checks, $path ) {
        if ( empty( $path ) ) {
            return;
        }

        $normalized = wp_normalize_path( $path );
        if ( isset( $checks[ $normalized ] ) ) {
            return;
        }

        $url = $this->path_to_url( $normalized );
        if ( empty( $url ) ) {
            return;
        }

        $checks[ $normalized ] = [
            'file' => $this->path_to_relative( $normalized ),
            'path' => $normalized,
            'url'  => $url,
        ];
    }

    private function add_glob_checks( array &$checks, $dir, array $patterns, $limit = 40 ) {
        if ( empty( $dir ) || ! is_dir( $dir ) ) {
            return $limit;
        }

        $base = trailingslashit( $dir );

        foreach ( $patterns as $pattern ) {
            if ( $limit <= 0 ) {
                break;
            }

            $matches = glob( $base . $pattern, GLOB_NOSORT );
            if ( empty( $matches ) ) {
                continue;
            }

            foreach ( $matches as $match ) {
                if ( $limit <= 0 ) {
                    break;
                }
                // Filter out symlinks to prevent following them
                if ( is_link( $match ) ) {
                    continue;
                }
                if ( is_file( $match ) ) {
                    // Exclude legitimate files (not backups)
                    $basename = basename( $match );
                    if ( in_array( $basename, [
                        'wp-config.php',         // Legitimate config
                        'wp-config-sample.php',  // Legitimate sample file
                        '.htaccess',             // Legitimate htaccess
                    ], true ) ) {
                        continue;
                    }
                    $this->add_check_path( $checks, $match );
                    $limit--;
                }
            }
        }

        return $limit;
    }

    private function path_to_url( $path ) {
        $root = wp_normalize_path( trailingslashit( ABSPATH ) );
        $content_dir = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : ( $root . 'wp-content' );
        $content_dir = wp_normalize_path( trailingslashit( $content_dir ) );

        $normalized = wp_normalize_path( $path );

        if ( strpos( $normalized, $content_dir ) === 0 ) {
            $rel = ltrim( substr( $normalized, strlen( $content_dir ) ), '/' );
            return content_url( '/' . $rel );
        }

        if ( strpos( $normalized, $root ) === 0 ) {
            $rel = ltrim( substr( $normalized, strlen( $root ) ), '/' );
            return site_url( '/' . $rel );
        }

        return '';
    }

    private function path_to_relative( $path ) {
        $root = wp_normalize_path( trailingslashit( ABSPATH ) );
        $normalized = wp_normalize_path( $path );

        if ( strpos( $normalized, $root ) === 0 ) {
            return ltrim( substr( $normalized, strlen( $root ) ), '/' );
        }

        $real = realpath( $path );
        if ( $real !== false ) {
            $real = wp_normalize_path( $real );
            if ( strpos( $real, $root ) === 0 ) {
                return ltrim( substr( $real, strlen( $root ) ), '/' );
            }
        }

        if ( strpos( $normalized, '/' ) !== 0 ) {
            return ltrim( $normalized, '/' );
        }

        return $normalized;
    }

    private function is_publicly_accessible( $url ) {
        if ( empty( $url ) ) {
            return false;
        }

        $probe = $this->probe_url( $url, 'HEAD' );
        if ( $probe === null ) {
            return false;
        }

        // Some servers reject HEAD; fall back to GET.
        if ( $probe['code'] === 405 || $probe['code'] === 0 ) {
            $probe = $this->probe_url( $url, 'GET' );
            if ( $probe === null ) {
                return false;
            }
        }

        if ( ! $this->is_ok_status( $probe['code'] ) ) {
            return false;
        }

        return ! $this->looks_like_soft_404( $url, $probe['response'] );
    }

    private function probe_url( $url, $method ) {
        // Don't follow redirects: a 3xx (e.g. a rule bouncing the request to the
        // homepage) is not the file itself, so let it fail the status check.
        $args = [
            'timeout'     => 6,
            'redirection' => 0,
            'headers'     => [
                'Cache-Control' => 'no-cache',
                'Pragma'        => 'no-cache',
            ],
        ];

        if ( 'GET' === $method ) {
            // Cap the read so a Range-ignoring server can't make us buffer a
            // full exposed archive in memory.
            $args['headers']['Range']    = 'bytes=0-32767';
            $args['limit_response_size'] = 32768;
            $response = wp_remote_get( $url, $args );
        } else {
            $response = wp_remote_head( $url, $args );
        }

        if ( is_wp_error( $response ) ) {
            return null;
        }

        return [
            'code'     => (int) wp_remote_retrieve_response_code( $response ),
            'response' => $response,
        ];
    }

    private function is_ok_status( $code ) {
        return ( $code >= 200 && $code < 300 ) || 206 === $code;
    }

    private function looks_like_soft_404( $url, $response ) {
        $path = (string) wp_parse_url( $url, PHP_URL_PATH );
        $ext  = strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) );

        // Executable scripts and HTML files legitimately serve HTML, so content
        // type alone can't flag them.
        $expects_html = in_array( $ext, [ 'php', 'phtml', 'php5', 'php7', 'phps', 'html', 'htm' ], true );

        $content_type = strtolower( (string) wp_remote_retrieve_header( $response, 'content-type' ) );
        $is_html      = ( '' !== $content_type && false !== strpos( $content_type, 'text/html' ) );

        // A data/archive file served as HTML is an error page, not the file.
        if ( ! $expects_html ) {
            return $is_html;
        }

        // For HTML targets, check the body for "not found" markers instead.
        $body = (string) wp_remote_retrieve_body( $response );
        if ( '' === $body ) {
            $get  = $this->probe_url( $url, 'GET' );
            $body = ( null === $get ) ? '' : (string) wp_remote_retrieve_body( $get['response'] );
        }

        return $this->body_signals_not_found( $body );
    }

    private function body_signals_not_found( $body ) {
        if ( '' === $body ) {
            return false;
        }

        $body = strtolower( $body );
        $markers = [
            'error404',
            'error-404',
            '404 not found',
            'page not found',
            '<title>404',
            '<title>not found',
        ];

        foreach ( $markers as $marker ) {
            if ( false !== strpos( $body, $marker ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Create ScanResult array with context-aware description.
     * 
     * @param array $check
     * @return array
     */
    private function create_result( $check ) {
        $data = $this->get_contextual_data( $check['file'] );
        $remediation = $this->get_remediation_plan( $check['file'] );

        return [
            'id'          => uniqid( 'sensitive_' ),
            'hash'        => IssueRepository::generateHash( 'sensitive_data', $check['file'] ),
            'type'        => IssueType::SENSITIVE_DATA,
            'status'      => 'warning',
            'severity'    => $data['severity'],
            'file'        => $check['path'],
            'message'     => $data['title'],
            'description' => $data['description'],
            'details'     => $data['description'],
            'created_at'  => time(),
            'slug'        => 'sensitive_data',
            'data'        => [
                'url' => $check['url'],
                'description' => $data['description'],
                'threat_context' => $data['description'],
                'exposure_label' => $data['context_label'],
                'exposure_value' => $data['context_value'],
                'preferred_action' => $remediation['primary'],
                'available_actions' => $remediation['actions'],
            ],
            'meta'        => [
                'label'  => 'Sensitive File Exposure: ' . $check['file'],
                'layout' => 'generic_card',
                'actions' => $remediation['actions']
            ]
        ];
    }

    /**
     * Determine remediation strategy by file risk profile.
     *
     * @param string $filename Relative file path.
     * @return array{primary:string,actions:array}
     */
    private function get_remediation_plan( $filename ) {
        $lower = strtolower( (string) $filename );
        $ext = strtolower( (string) pathinfo( $lower, PATHINFO_EXTENSION ) );

        // Request-driven policy:
        // - config/log/sql/git class exposures: protect access first
        // - archives/backups: delete first
        $protect_first = (
            strpos( $lower, '.env' ) !== false ||
            strpos( $lower, '.git/' ) !== false ||
            strpos( $lower, '.svn/' ) !== false ||
            strpos( $lower, 'wp-config' ) !== false ||
            strpos( $lower, '.htaccess' ) !== false ||
            strpos( $lower, '.htpasswd' ) !== false ||
            strpos( $lower, 'composer.' ) !== false ||
            strpos( $lower, 'package.json' ) !== false ||
            strpos( $lower, 'yarn.lock' ) !== false ||
            $ext === 'log' ||
            substr( $lower, -4 ) === '_log' ||
            $ext === 'sql' ||
            strpos( $lower, '.sql' ) !== false
        );

        $delete_first = in_array( $ext, [ 'zip', 'tar', 'gz', 'tgz', 'rar', '7z', 'bak', 'backup', 'old' ], true );

        if ( $delete_first && ! $protect_first ) {
            return [
                'primary' => 'delete',
                'actions' => [ 'delete', 'ignore' ],
            ];
        }

        return [
            'primary' => 'protect',
            'actions' => [ 'protect', 'delete', 'view', 'ignore' ],
        ];
    }

    /**
     * Generate professional title and description based on file type.
     */
    private function get_contextual_data( $filename ) {
        $ext = strtolower( (string) pathinfo( $filename, PATHINFO_EXTENSION ) );
        $lower_name = strtolower( $filename );

        if (
            strpos( $lower_name, 'wp-config' ) !== false ||
            strpos( $lower_name, '.env' ) !== false ||
            strpos( $lower_name, '.htaccess' ) !== false ||
            strpos( $lower_name, '.htpasswd' ) !== false
        ) {
            // .htaccess only leaks server directives; the rest leak credentials.
            $is_credential = (
                strpos( $lower_name, 'wp-config' ) !== false ||
                strpos( $lower_name, '.env' ) !== false ||
                strpos( $lower_name, '.htpasswd' ) !== false
            );
            return [
                'title' => "Exposed Critical Configuration: {$filename}",
                'description' => "This file contains security-sensitive configuration values. Public access can reveal credentials, keys, and server directives that attackers can use to compromise the site.",
                'context_label' => 'Exposure Type',
                'context_value' => 'Critical Configuration File',
                'severity' => $is_credential ? 'critical' : 'high',
            ];
        }

        if ( strpos( $lower_name, '.git/' ) !== false || strpos( $lower_name, '.svn/' ) !== false ) {
            return [
                'title' => "Exposed Repository Metadata: {$filename}",
                'description' => "Repository metadata is publicly reachable. Attackers can use this to reconstruct source history and discover secrets, internal paths, or deployment details.",
                'context_label' => 'Exposure Type',
                'context_value' => 'Source Control Metadata',
                'severity' => 'high',
            ];
        }

        if ( $ext === 'sql' || strpos( $lower_name, 'dump' ) !== false || strpos( $lower_name, 'database' ) !== false ) {
            return [
                'title' => "Exposed Database Dump: {$filename}",
                'description' => "A database export is publicly accessible. This may expose user records, password hashes, customer data, and internal configuration data.",
                'context_label' => 'Exposure Type',
                'context_value' => 'Database Export File',
                'severity' => 'critical',
            ];
        }

        if ( in_array( $ext, [ 'zip', 'tar', 'gz', 'tgz', 'rar', '7z', 'bak', 'backup', 'old' ], true ) ) {
            return [
                'title' => "Exposed Site Archive: {$filename}",
                'description' => "A backup or archive file is publicly downloadable. This can disclose source code, plugin data, and sensitive configuration that should never be public.",
                'context_label' => 'Exposure Type',
                'context_value' => 'Backup / Archive File',
                'severity' => 'high',
            ];
        }

        // *.log and error_log, but not names like catalog.zip that contain "log".
        if ( $ext === 'log' || substr( $lower_name, -4 ) === '_log' ) {
            return [
                'title' => "Exposed Log File: {$filename}",
                'description' => "A log file is publicly accessible. Logs often expose internal paths, stack traces, plugin names, and operational metadata that can accelerate targeted attacks.",
                'context_label' => 'Exposure Type',
                'context_value' => 'Operational Log File',
                'severity' => 'medium',
            ];
        }

        if (
            $lower_name === 'package.json' ||
            $lower_name === 'composer.json' ||
            $lower_name === 'composer.lock' ||
            $lower_name === 'yarn.lock'
        ) {
            return [
                'title' => "Exposed Project Metadata: {$filename}",
                'description' => "Dependency metadata is exposed publicly. Attackers can fingerprint package versions and target known vulnerabilities in your software stack.",
                'context_label' => 'Exposure Type',
                'context_value' => 'Dependency Metadata File',
                'severity' => 'low',
            ];
        }

        if ( strpos( $lower_name, 'info.php' ) !== false ) {
            return [
                'title' => "Exposed Server Information: {$filename}",
                'description' => "A diagnostic endpoint is exposed publicly. It reveals runtime and environment details that can be used to craft server-specific exploit attempts.",
                'context_label' => 'Exposure Type',
                'context_value' => 'Server Diagnostics Endpoint',
                'severity' => 'high',
            ];
        }

        if ( strpos( $lower_name, 'adminer' ) !== false || strpos( $lower_name, 'searchreplace' ) !== false ) {
            return [
                'title' => "Exposed Admin Utility: {$filename}",
                'description' => "A database or maintenance utility is publicly exposed. If accessed by unauthorized users, it can enable direct data modification or system abuse.",
                'context_label' => 'Exposure Type',
                'context_value' => 'Administrative Utility',
                'severity' => 'critical',
            ];
        }

        if ( strpos( $lower_name, '.idea/' ) !== false || strpos( $lower_name, '.vscode/' ) !== false ) {
            return [
                'title' => "Exposed Development Metadata: {$filename}",
                'description' => "Development workspace metadata is publicly exposed. While not always critical, it can reveal project structure and internal tooling information.",
                'context_label' => 'Exposure Type',
                'context_value' => 'Development Metadata File',
                'severity' => 'low',
            ];
        }

        // Default
        return [
            'title' => "Publicly Accessible Sensitive File: {$filename}",
            'description' => "This file is publicly accessible and may expose internal or sensitive operational data. Public access should be restricted unless explicitly required.",
            'context_label' => 'Exposure Type',
            'context_value' => 'Sensitive Public File',
            'severity' => 'medium',
        ];
    }
}
