<?php
/**
 * @package jDownloads
 * @version 4.1
 * @copyright (C) 2007 - 2025 - Arno Betz - www.jdownloads.com
 * @license http://www.gnu.org/copyleft/gpl.html GNU/GPL
 *
 * AJAX Worker for Modern Monitoring System
 * This file handles the actual scanning in chunks and reports progress via JSON
 *
 * FIXES in this version:
 *  1. The application object now gets a language object (fixes "Call to a member function getTag() on null")
 *  2. Error log lines use Text::_() correctly (the closing parenthesis was in the wrong place)
 *  3. Failed category/download creation is counted and shown in the log summary
 *  4. createNewCategory()/createNewDownload() report file and line of an exception
 */

// Critical: Suppress ALL output before JSON
@ini_set('display_errors', '0');
error_reporting(0);

// Start output buffering to catch any errors
ob_start();

@ini_set('magic_quotes_runtime', 0);

define('_JEXEC', 1);

if (!defined('DS')){
    define( 'DS', DIRECTORY_SEPARATOR );
}

define('JPATH', dirname(__FILE__) );

$parts = explode( DS, JPATH );
$script_root =  implode( DS, $parts ) ;

// check path
$x = array_search ( 'administrator', $parts  );
if (!$x) exit;

$path = '';
for ($i=0; $i < $x; $i++){
    $path = $path.$parts[$i].'/';
}
// remove last DS
$path = substr($path, 0, -1);

if (!defined('JPATH_BASE')){
    define('JPATH_BASE', $path );
}

setlocale(LC_ALL, 'C.UTF-8', 'C');

// Run the application
require_once JPATH_BASE . '/includes/defines.php';
require_once JPATH_BASE . '/includes/framework.php';

// Use statements MUST be at top level
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Application\ApplicationHelper;
use Joomla\Filesystem\File;
use Joomla\Filesystem\Folder;
use Joomla\CMS\Table\Table;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Filter\InputFilter;
use Joomla\Database\DatabaseInterface;

use JDownloads\Component\JDownloads\Administrator\Helper\JDownloadsHelper;
use JDownloads\Component\JDownloads\Administrator\Model\CategoryModel;
use JDownloads\Component\JDownloads\Administrator\Model\DownloadModel;

// Wrap everything in try-catch for clean error handling
try {

// Boot the DI container
$container = \Joomla\CMS\Factory::getContainer();

// Alias session services
$container->alias('session.web', 'session.web.site')
    ->alias('session', 'session.web.site')
    ->alias('JSession', 'session.web.site')
    ->alias(\Joomla\CMS\Session\Session::class, 'session.web.site')
    ->alias(\Joomla\Session\Session::class, 'session.web.site')
    ->alias(\Joomla\Session\SessionInterface::class, 'session.web.site');

// Instantiate the application.
$app = $container->get(\Joomla\CMS\Application\AdministratorApplication::class);
$app->createExtensionNamespaceMap();
\Joomla\CMS\Factory::$application = $app;

/* Required Files */
require_once ( $path . '/components/com_jdownloads/src/Helper/CategoriesHelper.php');
require_once ( $path . '/components/com_jdownloads/src/Helper/QueryHelper.php');
require_once ( $path . '/administrator/components/com_jdownloads/src/Helper/JDownloadsHelper.php');

// Get services
$database = Factory::getContainer()->get(DatabaseInterface::class);
$session = Factory::getContainer()->get(\Joomla\Session\Session::class);

// Backend language to prefer for admin scripts
$backend_lang = ComponentHelper::getParams('com_languages')->get('administrator', 'en-GB');

// Language loading
$lang = Factory::getLanguage();
$lang->load('com_jdownloads', JPATH_ADMINISTRATOR, $backend_lang, true);

// FIX: Attach the language object to the application.
// In this standalone script the app has no language set, so any code that calls
// Factory::getApplication()->getLanguage()->getTag() fails with
// "Call to a member function getTag() on null" (e.g. in DownloadModel::save()).
if (method_exists($app, 'setLanguage')) {
    $app->setLanguage($lang);
} elseif (method_exists($app, 'loadLanguage')) {
    $app->loadLanguage($lang);
}

// Register table path
Table::addIncludePath(JPATH_ADMINISTRATOR . '/components/com_jdownloads/src/Table');

// Get input
$jinput = Factory::getApplication()->getInput();

// Security check
$config = $app->getConfig();
$params = ComponentHelper::getParams('com_jdownloads');
$secret = $params->get('scan_secret_key');
$key = $jinput->get('key', '', 'string');

// Clear any buffered output FIRST
ob_clean();

// Set header for JSON response (only if not already sent)
if (!headers_sent()) {
    header('Content-Type: application/json; charset=utf-8');
}

if ($secret === '') {
    jsonResponse([
        'success' => false,
        'error' => 'Missing security key configuration'
    ]);
}

// Security check: Verify secret key
if ($key != $secret){
    jsonResponse([
        'success' => false,
        'error' => 'Invalid security key'
    ]);
}

// Security check: Verify user is logged in and is admin
$user = Factory::getUser();
if (!$user || $user->id == 0 || !$user->authorise('core.manage', 'com_jdownloads')) {
    jsonResponse([
        'success' => false,
        'error' => 'Unauthorized: You do not have permission to run this scan'
    ]);
}

// Get action
$action = $jinput->get('action', 'status', 'string');

// Handle different actions
switch ($action) {
    case 'start':
        handleStart();
        break;
    case 'process':
        handleProcess();
        break;
    case 'status':
        handleStatus();
        break;
    case 'reset':
        handleReset();
        break;
    case 'export_log':
        handleExportLog();
        break;
    default:
        jsonResponse(['success' => false, 'error' => 'Invalid action']);
}

// This point should never be reached because jsonResponse() calls exit()
// But just in case, clean up the buffer
ob_end_clean();
exit;

} catch (Exception $e) {
    // Catch any fatal errors and return clean JSON
    ob_clean();
    if (!headers_sent()) {
        header('Content-Type: application/json; charset=utf-8');
    }
    echo json_encode([
        'success' => false,
        'error' => $e->getMessage(),
        'file' => basename($e->getFile()),
        'line' => $e->getLine(),
        'type' => 'Exception'
    ], JSON_UNESCAPED_UNICODE);
    exit;
} catch (Error $e) {
    // Catch PHP 7+ errors
    ob_clean();
    if (!headers_sent()) {
        header('Content-Type: application/json; charset=utf-8');
    }
    echo json_encode([
        'success' => false,
        'error' => $e->getMessage(),
        'file' => basename($e->getFile()),
        'line' => $e->getLine(),
        'type' => 'Error'
    ], JSON_UNESCAPED_UNICODE);
    exit;
} catch (Throwable $e) {
    // Catch everything else
    ob_clean();
    if (!headers_sent()) {
        header('Content-Type: application/json; charset=utf-8');
    }
    echo json_encode([
        'success' => false,
        'error' => $e->getMessage(),
        'file' => basename($e->getFile()),
        'line' => $e->getLine(),
        'type' => 'Throwable'
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

// Start a new scan: create log file and initialise scan session data
function handleStart() {
    global $session, $jinput;

    $mode = $jinput->get('mode', 0, 'int');
    $testrun = $jinput->get('test', 0, 'int');
    $log_save = $jinput->get('log', 1, 'int'); // Default: save log (like scan.php)

    // Create temporary log file
    $session_id = $session->getId();
    // Fallback if session ID is empty: use timestamp + random
    if (empty($session_id)) {
        $session_id = time() . '_' . bin2hex(random_bytes(8));
    }
    // Normalize path separators for Windows compatibility
    $tmp_dir = str_replace(['/', '\\'], DS, JPATH_BASE) . DS . 'tmp';
    $log_file = $tmp_dir . DS . 'jd_scan_' . $session_id . '.log';

    // Clean up old scan log files (older than 1 hour)
    $files = glob($tmp_dir . DS . 'jd_scan_*.log');
    if ($files) {
        $one_hour_ago = time() - 3600;
        foreach ($files as $file) {
            if (filemtime($file) < $one_hour_ago) {
                @unlink($file);
            }
        }
    }

    // Clear current log file if exists
    if (File::exists($log_file)) {
        @unlink($log_file);
    }

    // Create empty log file to ensure it exists
    @file_put_contents($log_file, '');


    // Initialize scan session (without log array - stored in file)
    $scanData = [
        'mode' => $mode,
        'testrun' => $testrun,
        'log_save' => $log_save,
        'log_file' => $log_file,
        'state_version' => 0,
        'started' => time(),
        'started_microtime' => microtime(true),
        'number_of_runs' => 0,
        'phase' => 'initializing',
        'progress' => 0,
        'total' => 0,
        'current' => 0,
        'completed' => false,
        'stats' => [
            'new_cats' => 0,
            'new_downloads' => 0,
            'failed_cats' => 0,
            'failed_downloads' => 0,
            'missing_cats' => 0,
            'missing_files' => 0,
            'new_testrun_file' => 0
        ],
        'missing_cat_ids' => []
    ];

    updateTimingData($scanData);

    $session->set('jd_scan_progress', $scanData);

    jsonResponse([
        'success' => true,
        'message' => 'Scan initialized',
        'data' => $scanData
    ]);
}

// Main processing function: handles scan phases and updates progress
function handleProcess() {
    global $session, $params, $database;

    $scanData = $session->get('jd_scan_progress', null);

    if (!$scanData) {
        jsonResponse(['success' => false, 'error' => 'No scan in progress']);
    }

    try {
        // Get configuration
        $mode = $scanData['mode'];
        $testrun = $scanData['testrun'];
        $scanData['number_of_runs'] = (int) ($scanData['number_of_runs'] ?? 0) + 1;

        // Process based on current phase
        switch ($scanData['phase']) {
            case 'initializing':
                initializeScan($scanData);
                break;
            case 'scanning_folders':
                processFolderChunk($scanData, $testrun);
                break;
            case 'scanning_files':
                processFileChunk($scanData, $testrun);
                break;
            case 'checking_missing_cats':
                checkMissingCategoriesChunk($scanData, $testrun);
                break;
            case 'checking_missing_files':
                checkMissingFilesChunk($scanData, $testrun);
                break;
            default:
                $scanData['completed'] = true;
                $scanData['phase'] = 'completed';
        }

        // Update progress percentage
        if ($scanData['total'] > 0) {
            $scanData['progress'] = min(100, ($scanData['current'] / $scanData['total']) * 100);
        }

        updateTimingData($scanData);
        $scanData['state_version'] = (int) ($scanData['state_version'] ?? 0) + 1;

        // Save monitoring log when scan is completed
        if ($scanData['completed'] && !isset($scanData['log_saved'])) {
            saveMonitoringLog($scanData, $testrun);
            $scanData['log_saved'] = true; // Mark as saved to prevent duplicate saves
        }

        $session->set('jd_scan_progress', $scanData);

        // Add recent log entries for response
        if (isset($scanData['log_file'])) {
            $scanData['log'] = getRecentLogs($scanData['log_file'], 100);
        } else {
            $scanData['log'] = [];
        }

        jsonResponse([
            'success' => true,
            'data' => $scanData
        ]);

    } catch (Exception $e) {
        updateTimingData($scanData);
        addLog($scanData, 'ERROR: ' . $e->getMessage());
        $scanData['state_version'] = (int) ($scanData['state_version'] ?? 0) + 1;
        $session->set('jd_scan_progress', $scanData);

        // Add recent log entries for error response
        if (isset($scanData['log_file'])) {
            $scanData['log'] = getRecentLogs($scanData['log_file'], 100);
        } else {
            $scanData['log'] = [];
        }

        jsonResponse([
            'success' => false,
            'error' => $e->getMessage(),
            'data' => $scanData
        ]);
    }
}

// Initialize scan: set up exclude/include folders and start first step
function initializeScan(&$scanData) {
    global $params, $session;

    $mode = $scanData['mode'];
    $jd_root = $params->get('files_uploaddir') . '/';

    // Build exclude folders
    $temp_dir = $jd_root . $params->get('tempzipfiles_folder_name') . '/';
    $preview_dir = $jd_root . $params->get('preview_files_folder_name') . '/';
    $exclude_folders = [$temp_dir, $preview_dir];
    $include_folders = [];

    // Handle folder filtering
    if (!$params->get('all_folders_autodetect')) {
        $specified_folder_name_types = $params->get('include_or_exclude');
        $specified_folder_names = preg_split("/\\r\\n|\\r|\\n/", $params->get('include_or_exclude_folders'));
        $specified_folder_names = array_filter($specified_folder_names);

        foreach ($specified_folder_names as &$specified_folder_name) {
            $specified_folder_name = $jd_root . $specified_folder_name . '/';
        }

        if ($specified_folder_name_types == 1) {
            $exclude_folders = array_merge($exclude_folders, $specified_folder_names);
        } else {
            $include_folders = $specified_folder_names;
        }
    }

    $scanData['exclude_folders'] = $exclude_folders;
    $scanData['include_folders'] = $include_folders;
    $scanData['jd_root'] = $jd_root;

    // Always scan: folders → files → missing_cats → missing_files (Mode 0)
    $scanData['phase'] = 'scanning_folders';

    // Initialize log file with start message
    //addLog($scanData, Text::_('COM_JDOWNLOADS_RUN_MONITORING_INFO2'));

    $scanData['initialized'] = true;
    $session->set('jd_scan_progress', $scanData);
}

// Search for new directories to create them as new categories
function processFolderChunk(&$scanData, $testrun) {
    global $database, $params, $session;

    // Log Phase 1 starting message (only on first call)
    if (!isset($scanData['phase_1_started'])) {
        addLog($scanData, Text::_('COM_JDOWNLOADS_RUN_MONITORING_INFO3'));
        $scanData['phase_1_started'] = true;
    }

    // Get chunk size from params
    $chunkSize = (int)$params->get('scan_chunk_size', 100);

    // Lazy load folder list
    if (!isset($scanData['folders_to_scan'])) {
        // Add run hint to log
        //addLog($scanData, Text::_('COM_JDOWNLOADS_RUN_MONITORING_INFO3'));

        $folders = JDownloadsHelper::searchdir(
            $scanData['jd_root'],
            -1,
            'DIRS',
            0,
            $scanData['exclude_folders'],
            $scanData['include_folders']
        );

        // Filter and prepare folders
        $searchdirs = [];
        foreach ($folders as $folder) {
            if (!JDownloadsHelper::findStringInArray($scanData['exclude_folders'], $folder)
                && $scanData['jd_root'] != $folder) {
                $folder = str_replace($scanData['jd_root'], '', $folder);
                if ($pos = strrpos($folder, '/')) {
                    $searchdirs[] = substr($folder, 0, $pos);
                }
            }
        }

        $scanData['folders_to_scan'] = $searchdirs;
        $scanData['total'] = count($searchdirs);
        $scanData['folder_index'] = 0;

        addLog($scanData, Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_SUM_FOLDERS') . ' ' . count($searchdirs));
    }

    // Get existing categories
    $database->setQuery("SELECT concat(cat_dir_parent, '/', cat_dir) AS path FROM #__jdownloads_categories WHERE cat_dir != ''");
    $existing_categories = $database->loadColumn();

    // Process chunk
    $processed = 0;
    $folders = $scanData['folders_to_scan'];
    $startIndex = $scanData['folder_index'];

    for ($i = $startIndex; $i < count($folders) && $processed < $chunkSize; $i++) {
        $folder = $folders[$i];

        // Check if folder exists as category
        $dirs = explode('/', $folder);
        $sum = count($dirs);

        if ($sum == 1) {
            $cat_exist = in_array('/' . $folder, $existing_categories);
            $cat_dir_parent_value = '';
            $cat_dir_value = $dirs[0];
        } else {
            $cat_exist = in_array($folder, $existing_categories);
            $pos = strrpos($folder, '/');
            $cat_dir_parent_value = substr($folder, 0, $pos);
            $cat_dir_value = substr($folder, $pos + 1);
        }

        // Create new category if it doesn't exist
        if (!$cat_exist) {
            addLog($scanData, Text::sprintf('COM_JDOWNLOADS_AUTO_CHECK_NEW_CAT_FOUND', ' → ' . $folder ));

            $scanData['stats']['new_cats']++;

            if (!$testrun) {
                // Actual implementation: Create category
                $result = createNewCategory($folder, $cat_dir_value, $cat_dir_parent_value, $sum);
                if ($result['success']) {
                    $log_msg = Text::sprintf('COM_JDOWNLOADS_AUTO_CAT_CHECK_ADDED', ': ✓ ' . $folder );
                    addLog($scanData, $log_msg);
                } else {
                    $scanData['stats']['failed_cats'] = (int) ($scanData['stats']['failed_cats'] ?? 0) + 1;
                    // FIX: closing parenthesis of Text::_() was in the wrong place
                    $log_msg = Text::_('COM_JDOWNLOADS_ERROR_RESULT_MSG') . ': ✗ ' . $folder . ': ' . $result['error'];
                    addLog($scanData, $log_msg);
                }
            }
        }

        $processed++;
        $scanData['current']++;
        $scanData['folder_index'] = $i + 1;
    }

    // Check if folder scanning is complete
    if ($scanData['folder_index'] >= count($folders)) {
        // Always continue to file scanning (Mode 0)
        $scanData['phase'] = 'scanning_files';
        $scanData['current'] = 0;
        $scanData['total'] = 0;

        // Show summary message for Phase 1
        $new_cats_summary_key = $testrun
            ? 'COM_JDOWNLOADS_BACKEND_AUTOCHECK_NEW_CATS_TESTRUN'
            : 'COM_JDOWNLOADS_BACKEND_AUTOCHECK_NEW_CATS';

        if ($scanData['stats']['new_cats'] > 0) {
            addLog($scanData, (int) $scanData['stats']['new_cats'] . ' ' . Text::_($new_cats_summary_key));
        } else {
            addLog($scanData, Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_NO_NEW_CATS'));
        }

        if (!$testrun && (int) ($scanData['stats']['failed_cats'] ?? 0) > 0) {
            addLog($scanData, '✗ ' . (int) $scanData['stats']['failed_cats'] . ' categories could not be created (see errors above)');
        }
    }
    $session->set('jd_scan_progress', $scanData);
}

// Search for new files to create them as new downloads
function processFileChunk(&$scanData, $testrun) {
    global $database, $params, $session, $secret;

    // Log Phase 2 starting message (only on first call)
    if (!isset($scanData['phase_2_started'])) {
        addLog($scanData, Text::_('COM_JDOWNLOADS_RUN_MONITORING_INFO5'));
        $scanData['phase_2_started'] = true;
    }

    // Get chunk size from params
    $chunkSize = (int)$params->get('scan_chunk_size', 100);

    // Lazy-load file list
    if (!isset($scanData['files_to_scan'])) {
        $files = [];
        $file_types = [];

        if (!$params->get('all_files_autodetect')) {
            $file_types = explode(',', $params->get('file_types_autodetect'));
        }

        $all_dirs = JDownloadsHelper::scan_dir(
            $scanData['jd_root'],
            $scanData['exclude_folders'],
            $scanData['include_folders'],
            $scanData['jd_root'],
            $files,
            $file_types,
            false,
            $params->get('all_files_autodetect'),
            true,
            true
        );

        // Convert associative array to indexed array for iteration
        // scan_dir returns: $files[fullpath] = ['path' => ..., 'file' => ..., 'size' => ..., 'date' => ...]
        $files_indexed = [];
        foreach ($files as $full_path => $file_info) {
            $files_indexed[] = [
                'full_path' => $full_path,
                'file' => $file_info['file'],
                'path' => $file_info['path'],
                'size' => $file_info['size'],
                'date' => $file_info['date']
            ];
        }

        $scanData['files_to_scan'] = $files_indexed;
        $scanData['total'] = count($files_indexed);
        $scanData['file_index'] = 0;
        $sumFilesString = Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_SUM_FILES') . ' ' . count($files_indexed);
        addLog($scanData, $sumFilesString, 0);

        $database->setQuery(
            "SELECT f.id, f.catid, f.md5_value, f.url_download, c.cat_dir, c.cat_dir_parent
             FROM #__jdownloads_files AS f
             LEFT JOIN #__jdownloads_categories AS c ON c.id = f.catid
             WHERE f.url_download != ''"
        );
        $existing_downloads = $database->loadObjectList();

        $existing_download_index = [];

        foreach ($existing_downloads as $download) {
            $category_path_key = buildCategoryPathKey($download->cat_dir_parent ?? '', $download->cat_dir ?? '');
            $download_key = $download->url_download . '|' . $category_path_key;
            $existing_download_index[$download_key] = $download;
        }

        $scanData['existing_download_index'] = $existing_download_index;
    }

    $compare_also_files_hash = (int) $params->get('compare_also_files_hash', 0);
    $update_modification_date = (int) $params->get('update_modification_date', 0);
    $update_update_status = (int) $params->get('update_update_status', 0);
    $check_sys_settings = JDownloadsHelper::explore($secret);
    $existing_download_index = $scanData['existing_download_index'];

    // Process chunk
    $processed = 0;
    $files = $scanData['files_to_scan'];
    $startIndex = $scanData['file_index'];

    for ($i = $startIndex; $i < count($files) && $processed < $chunkSize; $i++) {
        $file_info = $files[$i];
        $full_path = $file_info['full_path'];
        $filename = $file_info['file'];

        // Extract relative path and category info from full path
        // The file_path from scan_dir comes as: "category/subcategory/filename.zip"
        $file_path_rel = str_replace($scanData['jd_root'], '', $full_path);
        $file_path_rel = ltrim($file_path_rel, '/');

        // Extract category path from file path
        $pos = strrpos($file_path_rel, '/');
        if ($pos) {
            $only_dirs = substr($file_path_rel, 0, $pos);
            $pos2 = strrpos($only_dirs, '/');
            if ($pos2 !== false) {
                // Multiple levels: parent/child category
                $cat_dir_parent_value = substr($only_dirs, 0, $pos2);
                $cat_dir_value = substr($only_dirs, $pos2 + 1);
            } else {
                // Single level: only one category (no parent)
                $cat_dir_parent_value = '';
                $cat_dir_value = $only_dirs;
            }
        } else {
            $cat_dir_parent_value = '';
            $cat_dir_value = '';
        }

        $category_path_key = buildCategoryPathKey($cat_dir_parent_value, $cat_dir_value);
        $download_key = $filename . '|' . $category_path_key;

        // Check if this file (with same filename AND category) already exists as download
        $exist_file = false;

        if (isset($existing_download_index[$download_key])) {
            $download = $existing_download_index[$download_key];
            $exist_file = true;

            if ($compare_also_files_hash && $check_sys_settings) {
                // File already exists in this category, but check if hash changed.
                $hash = md5_file($full_path);

                if ($hash !== $download->md5_value) {
                    if (!$testrun) {
                        $update_date = Factory::getDate()->toSql();

                        if ($update_modification_date) {
                            if ($update_update_status) {
                                $database->setQuery("UPDATE #__jdownloads_files SET md5_value = " . $database->quote($hash) .
                                    ", modified = " . $database->quote($update_date) .
                                    ", update_active = 1 WHERE id = " . (int) $download->id);
                            } else {
                                $database->setQuery("UPDATE #__jdownloads_files SET md5_value = " . $database->quote($hash) .
                                    ", modified = " . $database->quote($update_date) .
                                    " WHERE id = " . (int) $download->id);
                            }
                        } else {
                            $database->setQuery("UPDATE #__jdownloads_files SET md5_value = " . $database->quote($hash) .
                                " WHERE id = " . (int) $download->id);
                        }

                        $database->execute();
                        $existing_download_index[$download_key]->md5_value = $hash;
                        $scanData['existing_download_index'] = $existing_download_index;

                        if ($update_update_status) {
                            $log_msg = Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_DATE_UPDATED2') . ': ' . $file_path_rel;
                        } else {
                            $log_msg = Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_DATE_UPDATED') . ': ' . $file_path_rel;
                        }
                        addLog($scanData, $log_msg);
                    } else {
                        if ($update_update_status) {
                            $log_msg = Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_DATE_COULD_UPDATED2') . ': ' . $file_path_rel;
                        } else {
                            $log_msg = Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_DATE_COULD_UPDATED') . ': ' . $file_path_rel;
                        }
                        addLog($scanData, $log_msg);
                    }
                }
            }
        }

        // Add the file here in a new Download if it doesn't exist
        if (!$exist_file) {
            $scanData['stats']['new_testrun_file']++;
            $log_msg = Text::sprintf('COM_JDOWNLOADS_AUTO_CHECK_NEW_FILE_FOUND', ' → ' . $file_path_rel);
            addLog($scanData, $log_msg);

            if (!$testrun) {
                $result = createNewDownload($full_path, $file_path_rel, $files[$i]);
                if ($result['success']) {
                    $scanData['stats']['new_downloads']++;
                    $log_msg = Text::sprintf('COM_JDOWNLOADS_AUTO_FILE_CHECK_ADDED', ': ✓ ' . $file_path_rel );
                    addLog($scanData, $log_msg);
                } else {
                    $scanData['stats']['failed_downloads'] = (int) ($scanData['stats']['failed_downloads'] ?? 0) + 1;
                    // FIX: closing parenthesis of Text::_() was in the wrong place
                    $log_msg = Text::_('COM_JDOWNLOADS_ERROR_RESULT_MSG') . ': ✗ ' . $file_path_rel . ': ' . $result['error'];
                    addLog($scanData, $log_msg);
                }
            } else {
                $scanData['stats']['new_downloads']++;
            }
        }

        $processed++;
        $scanData['current']++;
        $scanData['file_index'] = $i + 1;
    }

    // Check if file scanning is complete
    if ($scanData['file_index'] >= count($files)) {
        // Continue to check missing categories (Mode 0 flow)
        $scanData['phase'] = 'checking_missing_cats';
        $scanData['current'] = 0;
        $scanData['total'] = 0;

        // Show summary message for Phase 2
        $new_files_summary_key = $testrun
            ? 'COM_JDOWNLOADS_BACKEND_AUTOCHECK_NEW_FILES_TESTRUN'
            : 'COM_JDOWNLOADS_BACKEND_AUTOCHECK_NEW_FILES';

        if ($scanData['stats']['new_downloads'] > 0) {
            addLog($scanData, (int) $scanData['stats']['new_downloads'] . ' ' . Text::_($new_files_summary_key));
        } elseif ((int) ($scanData['stats']['failed_downloads'] ?? 0) == 0) {
            addLog($scanData, Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_NO_NEW_FILES'));
        }

        // Do not hide failures behind "No new Files found."
        if (!$testrun && (int) ($scanData['stats']['failed_downloads'] ?? 0) > 0) {
            addLog($scanData, '✗ ' . (int) $scanData['stats']['failed_downloads'] . ' files could not be added as downloads (see errors above)');
        }
    }

    $session->set('jd_scan_progress', $scanData);
}

// Check for missing directories and unpublish the corresponding categories
function checkMissingCategoriesChunk(&$scanData, $testrun) {
    global $database, $session, $params;

    $jd_root = $scanData['jd_root'];
    $chunkSize = (int)$params->get('scan_chunk_size', 100);

    // Lazy-load category list
    if (!isset($scanData['categories_to_check'])) {
        $database->setQuery("SELECT id, cat_dir, cat_dir_parent FROM #__jdownloads_categories WHERE published = 1 ORDER BY id ASC");
        $categories = $database->loadObjectList();

        $scanData['categories_to_check'] = $categories;
        $scanData['category_index'] = 0;
        $scanData['total'] = count($categories);
        $scanData['current'] = 0;

        addLog($scanData, Text::_('COM_JDOWNLOADS_RUN_MONITORING_INFO4'));
    }

    $categories = $scanData['categories_to_check'];
    $processed = 0;

    if (!isset($scanData['missing_cat_ids']) || !is_array($scanData['missing_cat_ids'])) {
        $scanData['missing_cat_ids'] = [];
    }

    // Process chunk
    for ($i = $scanData['category_index']; $i < count($categories) && $processed < $chunkSize; $i++) {
        $cat = $categories[$i];

        // Build category path
        if ($cat->cat_dir_parent != '') {
            $cat_dir = $jd_root . $cat->cat_dir_parent . '/' . $cat->cat_dir;
        } else {
            $cat_dir = $jd_root . $cat->cat_dir;
        }

        // Check if category directory exists on filesystem
        if (!is_dir($cat_dir)) {
            $cat_display_path = $cat->cat_dir_parent !== ''
                ? $cat->cat_dir_parent . '/' . $cat->cat_dir
                : $cat->cat_dir;

            if (!in_array((int) $cat->id, $scanData['missing_cat_ids'], true)) {
                $scanData['missing_cat_ids'][] = (int) $cat->id;
            }

            if (!$testrun) {
                $missing_cat_log_key = 'COM_JDOWNLOADS_AUTO_CAT_CHECK_UNPUBLISHED_WITH_DOWNLOADS';

                $database->setQuery("UPDATE #__jdownloads_categories SET published = 0 WHERE id = " . (int)$cat->id);
                $database->execute();

                $database->setQuery("UPDATE #__jdownloads_files SET published = 0 WHERE catid = " . (int) $cat->id);
                $database->execute();

                $scanData['stats']['missing_cats']++;
                addLog($scanData, '✗ ' . Text::sprintf($missing_cat_log_key, $cat_display_path));
            } else {
                $missing_cat_log_key = 'COM_JDOWNLOADS_AUTO_CAT_CHECK_WOULD_UNPUBLISH_WITH_DOWNLOADS';

                $scanData['stats']['missing_cats']++;
                addLog($scanData, '✗ ' . Text::sprintf($missing_cat_log_key, $cat_display_path));
            }
        }

        $processed++;
        $scanData['current']++;
        $scanData['category_index'] = $i + 1;
    }

    // Check if checking is complete
    if ($scanData['category_index'] >= count($categories)) {
        // Continue to check missing files
        $scanData['phase'] = 'checking_missing_files';
        $scanData['current'] = 0;
        $scanData['total'] = 0;
        $scanData['file_index'] = 0;

        // Show summary message for Phase 3
        if ($scanData['stats']['missing_cats'] > 0) {
            addLog($scanData, (int) $scanData['stats']['missing_cats'] . ' ' . Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_MISSING_CATS'));
        } else {
            addLog($scanData, Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_NO_MISSING_CATS'));
        }
    }

    $session->set('jd_scan_progress', $scanData);
}

// Check for missing files and apply the configured action
function checkMissingFilesChunk(&$scanData, $testrun) {
    global $database, $session, $params;

    $jd_root = $scanData['jd_root'];
    $chunkSize = (int)$params->get('scan_chunk_size', 100);

    // Lazy-load file list
    if (!isset($scanData['files_to_check'])) {
        $database->setQuery(
            "SELECT f.id, f.catid, f.url_download, c.cat_dir, c.cat_dir_parent
             FROM #__jdownloads_files AS f
             LEFT JOIN #__jdownloads_categories AS c ON c.id = f.catid
             WHERE f.published = 1
             ORDER BY f.id ASC"
        );
        $downloads = $database->loadObjectList();

        $scanData['files_to_check'] = $downloads;
        $scanData['file_index'] = 0;
        $scanData['total'] = count($downloads);
        $scanData['current'] = 0;

        addLog($scanData, Text::_('COM_JDOWNLOADS_RUN_MONITORING_INFO6'));
    }

    $downloads = $scanData['files_to_check'];
    $processed = 0;
    $missing_files_action = (string) $params->get('missing_files_action', 'unpublish');
    $missing_cat_ids = array_map('intval', (array) ($scanData['missing_cat_ids'] ?? []));
    $monitor_all_file_types = (int) $params->get('all_files_autodetect', 1) === 1;
    $monitored_file_types = [];

    if (!$monitor_all_file_types) {
        $configured_file_types = (string) $params->get('file_types_autodetect', '');
        $monitored_file_types = array_values(array_filter(array_map(static function ($type) {
            return strtolower(ltrim(trim((string) $type), '.'));
        }, explode(',', $configured_file_types)), static function ($type) {
            return $type !== '';
        }));
    }

    // Process chunk
    for ($i = $scanData['file_index']; $i < count($downloads) && $processed < $chunkSize; $i++) {
        $file = $downloads[$i];

        if (in_array((int) $file->catid, $missing_cat_ids, true)) {
            $processed++;
            $scanData['current']++;
            $scanData['file_index'] = $i + 1;
            continue;
        }

        if ($file->cat_dir === null) {
            $processed++;
            $scanData['current']++;
            $scanData['file_index'] = $i + 1;
            continue;
        }

        if ($file->cat_dir_parent != '') {
            $cat_dir_path = $file->cat_dir_parent . '/' . $file->cat_dir;
        } else {
            $cat_dir_path = $file->cat_dir;
        }

        $cat_dir_path_full = $jd_root . $cat_dir_path . '/';

        // Respect configured include/exclude folder filtering for missing-file checks.
        if (JDownloadsHelper::findStringInArray($scanData['exclude_folders'], $cat_dir_path_full)) {
            $processed++;
            $scanData['current']++;
            $scanData['file_index'] = $i + 1;
            continue;
        }

        if (!empty($scanData['include_folders'])
            && !JDownloadsHelper::findStringInArray($scanData['include_folders'], $cat_dir_path_full)) {
            $processed++;
            $scanData['current']++;
            $scanData['file_index'] = $i + 1;
            continue;
        }

        // We can only check downloads which have a file
        if ($file->url_download != '') {
            if (!$monitor_all_file_types) {
                $file_extension = strtolower((string) pathinfo((string) $file->url_download, PATHINFO_EXTENSION));

                if ($file_extension === '' || !in_array($file_extension, $monitored_file_types, true)) {
                    $processed++;
                    $scanData['current']++;
                    $scanData['file_index'] = $i + 1;
                    continue;
                }
            }

            $file_path = $jd_root . $cat_dir_path . '/' . $file->url_download;
            $display_path = $cat_dir_path . '/' . $file->url_download;

            if (!file_exists($file_path)) {
                if (!$testrun) {
                    $missing_file_log_key = 'COM_JDOWNLOADS_AUTO_FILE_CHECK_UNPUBLISHED';

                    if ($missing_files_action === 'delete_db') {
                        $download_deleted = false;

                        try {
                            $download_table = Table::getInstance('DownloadTable', 'JDownloads\\Component\\JDownloads\\Administrator\\Table\\');

                            if ($download_table && $download_table->load((int) $file->id)) {
                                $download_deleted = (bool) $download_table->delete((int) $file->id);
                                if ($download_deleted) {
                                    $missing_file_log_key = 'COM_JDOWNLOADS_AUTO_FILE_CHECK_DELETED_DB';
                                }
                            }
                        } catch (\Throwable $exception) {
                            $download_deleted = false;
                        }

                        if (!$download_deleted) {
                            $missing_file_log_key = 'COM_JDOWNLOADS_AUTO_FILE_CHECK_DELETE_DB_FAILED';
                        }
                    } else {
                        $database->setQuery("UPDATE #__jdownloads_files SET published = 0 WHERE id = " . (int) $file->id);
                        $database->execute();
                    }

                    $scanData['stats']['missing_files']++;
                    addLog($scanData, '✗ ' . Text::sprintf($missing_file_log_key, $display_path));
                } else {
                    $missing_file_log_key = $missing_files_action === 'delete_db'
                        ? 'COM_JDOWNLOADS_AUTO_FILE_CHECK_WOULD_DELETE_DB'
                        : 'COM_JDOWNLOADS_AUTO_FILE_CHECK_WOULD_UNPUBLISH';

                    $scanData['stats']['missing_files']++;
                    addLog($scanData, '✗ ' . Text::sprintf($missing_file_log_key, $display_path));
                }
            }
        }

        $processed++;
        $scanData['current']++;
        $scanData['file_index'] = $i + 1;
    }

    // Check if checking is complete
    if ($scanData['file_index'] >= count($downloads)) {
        // Scan is complete
        $scanData['completed'] = true;
        $scanData['phase'] = 'completed';
        $scanData['progress'] = 100;
        updateTimingData($scanData);

        // Show summary message for Phase 4
        if ($scanData['stats']['missing_files'] > 0) {
            $missing_files_summary_key = $testrun
                ? 'COM_JDOWNLOADS_BACKEND_AUTOCHECK_MISSING_FILES_TESTRUN'
                : 'COM_JDOWNLOADS_BACKEND_AUTOCHECK_MISSING_FILES';

            addLog($scanData, (int) $scanData['stats']['missing_files'] . ' ' . Text::_($missing_files_summary_key));
        } else {
            addLog($scanData, Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_NO_MISSING_FILES'));
        }

        addLog($scanData, '✓ ' . Text::_('COM_JDOWNLOADS_MONITORING_LOG_FINISHED'));
    }

    $session->set('jd_scan_progress', $scanData);
}

// Create a new category in the database
function createNewCategory($folder, $cat_dir_value, $cat_dir_parent_value, $sum) {
    global $database, $params, $session;

    try {
        // Get parent ID
        if ($sum == 1) {
            $parent_id = 1; // Root category
        } else {
            $pos = strrpos($cat_dir_parent_value, '/');
            if ($pos) {
                $cat_dir_parent_value2 = substr($cat_dir_parent_value, 0, $pos);
                $cat_dir_value2 = substr($cat_dir_parent_value, $pos + 1);
                $query = "SELECT * FROM #__jdownloads_categories WHERE cat_dir = " .
                         $database->quote($database->escape($cat_dir_value2)) .
                         " AND cat_dir_parent = " .
                         $database->quote($database->escape($cat_dir_parent_value2));
            } else {
                $query = "SELECT * FROM #__jdownloads_categories WHERE cat_dir = " .
                         $database->quote($database->escape($cat_dir_parent_value)) .
                         " AND cat_dir_parent = ''";
            }

            $database->setQuery($query);
            $parent_cat = $database->loadObject();

            if (!$parent_cat) {
                return ['success' => false, 'error' => 'Parent category "' . $cat_dir_parent_value . '" not found'];
            }

            $parent_id = $parent_cat->id;
        }

        // Clean folder name
        $checked_cat_dir = JDownloadsHelper::getCleanFolderFileName($cat_dir_value, true);
        $alias = ApplicationHelper::stringURLSafe($cat_dir_value);

        $use_default_values = (int) $params->get('autopublish_use_cat_default_values', 0);

        if ($use_default_values) {
            $desc = JDownloadsHelper::getOnlyLanguageSubstring($params->get('autopublish_default_cat_description', ''));
            $desc = InputFilter::getInstance()->clean($desc, 'string');
            $access = (int) $params->get('autopublish_cat_access_level', 0);
            $language = $params->get('autopublish_cat_language', '*');
            $tags = $params->get('autopublish_cat_tags', 0);
            $creator = (int) $params->get('autopublish_cat_created_by', 0);
            $cat_pic = $params->get('autopublish_cat_pic_default_filename', '');
        } else {
            $desc = '';
            $language = '*';
            $tags = '';
            $creator = 0;
            $cat_pic = $params->get('cat_pic_default_filename', '');
            $access = isset($parent_cat) && $parent_cat ? (int) $parent_cat->access : 1;
        }

        // Build category data
        $data = [
            'id' => 0,
            'parent_id' => $parent_id,
            'title' => $cat_dir_value,
            'alias' => $alias,
            'notes' => '',
            'description' => $desc,
            'cat_dir' => $checked_cat_dir,
            'cat_dir_parent' => $cat_dir_parent_value,
            'pic' => $cat_pic,
            'published' => (int)$params->get('autopublish_founded_files'),
            'access' => $access,
            'metadesc' => '',
            'metakey' => '',
            'language' => $language,
            'created_user_id' => $creator,
            'params' => [],
            'rules' => [],
        ];

        // Create category using model
        $model_category = new \JDownloads\Component\JDownloads\Administrator\Model\CategoryModel();
        $result = $model_category->createAutoCategory($data);

        if ($result) {
            return ['success' => true];
        } else {
            // Get error from model if available
            $error = $model_category->getError();
            return ['success' => false, 'error' => $error ?: 'Category creation failed'];
        }

    } catch (Throwable $e) {
        return ['success' => false, 'error' => $e->getMessage() . ' @ ' . basename($e->getFile()) . ':' . $e->getLine()];
    }
}

// Create a new download in the database
function createNewDownload($file_path, $relative_path, $file) {
    global $database, $params, $session;

    $app = Factory::getApplication();

    try {
        // Safety net: make sure the application has a language object
        // (DownloadModel::save() and Joomla's tag/association code expect it)
        if (method_exists($app, 'getLanguage') && $app->getLanguage() === null) {
            if (method_exists($app, 'setLanguage')) {
                $app->setLanguage(Factory::getLanguage());
            } elseif (method_exists($app, 'loadLanguage')) {
                $app->loadLanguage(Factory::getLanguage());
            }
        }

        // Extract category info from relative path (same logic as processFileChunk)
        $relative_path_clean = ltrim($relative_path, '/');

        // Find the last slash to separate category from filename
        $pos = strrpos($relative_path_clean, '/');
        if ($pos) {
            $only_dirs = substr($relative_path_clean, 0, $pos);

            // Determine cat_dir_parent and cat_dir from the directory path
            $pos2 = strrpos($only_dirs, '/');
            if ($pos2 !== false) {
                // Multiple levels: parent/child category
                $cat_dir_parent_value = substr($only_dirs, 0, $pos2);
                $cat_dir_value = substr($only_dirs, $pos2 + 1);
            } else {
                // Single level: no parent
                $cat_dir_parent_value = '';
                $cat_dir_value = $only_dirs;
            }
        } else {
            // No category path (file in root)
            $cat_dir_parent_value = '';
            $cat_dir_value = '';
        }

        // Find category ID using exact cat_dir and cat_dir_parent match
        $cat_id = null;
        $cat_access = null;

        if ($cat_dir_value) {
            // Query for category with exact directory match
            $database->setQuery("SELECT id, access FROM #__jdownloads_categories
                                WHERE cat_dir = " . $database->quote($cat_dir_value) .
                                " AND cat_dir_parent = " . $database->quote($cat_dir_parent_value));
            $cat_row = $database->loadObject();
            if ($cat_row) {
                $cat_id = (int) $cat_row->id;
                $cat_access = (int) $cat_row->access;
            }
        }

        // Fallback: use root category if not found
        if (!$cat_id) {
            $cat_id = 1; // Default to uncategorized/root category
            $cat_access = 1;
        }

        // Extract file info
        $filename = basename($file_path);

        // Get file details
        $only_name = File::stripExt($filename);
        $file_extension = File::getExt($filename);
        $file_size = $file['size'];

        // Build the title
        $title = InputFilter::getInstance()->clean($only_name, 'STRING');

        // Build the alias
        $alias = ApplicationHelper::stringURLSafe($title);

        // Calculate date
        $date = Factory::getDate();
        $tz = $app->getConfig()->get( 'offset' );
        $date->setTimezone(new DateTimeZone($tz));

        // Set creation date
        $creation_date = Factory::getDate()->toSql();

        // Set file mime pic
        $picpath = strtolower(JPATH_SITE.'/images/jdownloads/fileimages/'.$file_extension.'.png');
        if (file_exists($picpath)){
            $file_pic  = $file_extension.'.png';
        } else {
            $file_pic  = $params->get('file_pic_default_filename');
        }

        $images = '';

        // Create thumbs form pdf
        if ($params->get('create_pdf_thumbs') && $params->get('create_pdf_thumbs_by_scan') && $file_extension == 'pdf'){
            $thumb_file_type = strtolower($params->get('pdf_thumb_image_type'));

            // Make sure that we have a unique filename for the new pic
            $thumb_path = JPATH_SITE.'/images/jdownloads/screenshots/thumbnails/';
            $screenshot_path = JPATH_SITE.'/images/jdownloads/screenshots/';
            $picfilename     = basename($file_path);
            $only_name       = File::stripExt($picfilename);
            $file_extension  = File::getExt($picfilename);

            $thumbfilename   = $thumb_path.$only_name.'.'.$thumb_file_type;

            $num = 1;
            while (File::exists($thumbfilename)){
                $picfilename = $only_name.$num.'.'.$thumb_file_type;
                $thumbfilename = $thumb_path.$picfilename;
                $num++;
            }
            // Create now the new pdf thumbnail
            $only_name = File::stripExt($picfilename);
            $pdf_thumb_name = jdownloadsHelper::create_new_pdf_thumb($file_path, $only_name, $thumb_path, $screenshot_path);
            if ($pdf_thumb_name){
                $images = $pdf_thumb_name;
            }
        }

        // Create auto thumb when founded file is an image
        if ($params->get('create_auto_thumbs_from_pics') && $params->get('create_auto_thumbs_from_pics_by_scan')){
            if ($file_is_image = JDownloadsHelper::fileIsPicture($filename)){
                // Make sure that we have a unique filename for the new pic
                $thumbpath      = JPATH_SITE.'/images/jdownloads/screenshots/thumbnails/';
                $picfilename    = basename($file_path);
                $only_name      = File::stripExt($picfilename);
                $file_extension = File::getExt($picfilename);

                $thumbfilename = $thumbpath.$picfilename;

                $num = 1;
                while (File::exists($thumbfilename)){
                    $picfilename = $only_name.$num.'.'.$file_extension;
                    $thumbfilename = $thumbpath.$picfilename;
                    $num++;
                }
                // Create now the new thumbnail
                $thumb_created = jdownloadsHelper::create_new_thumb($file_path, $picfilename);
                if ($thumb_created){
                    $images = $picfilename;
                    // Create new big image for full view
                    $image_created = jdownloadsHelper::create_new_image($file_path, $picfilename);
                }
            }
        }

        // Use default values from params?
        $use_default_values = $params->get('autopublish_use_default_values', 0);

        // Use title rules?
        $title_rule = $params->get('autopublish_title_format_option', 0);

        if ($use_default_values){

            if ($title_rule > 0){
                $title = str_replace('-', ' ', $title);
                $title = str_replace('_', ' ', $title);
            }

            if ($title_rule == 2){
                $title = ucwords($title);
            }

            // Title must have a value
            if ($title == '') $title = 'Invalid Name!';

            $creator   = $params->get('autopublish_created_by', 0);
            $language  = $params->get('autopublish_language', '*');
            $desc      = JDownloadsHelper::getOnlyLanguageSubstring($params->get('autopublish_default_description', ''));
            $desc      = InputFilter::getInstance()->clean($desc, 'string');
            $access    = (int) $params->get('autopublish_access_level', 0);
            $tags      = $params->get('autopublish_tags', 0);
            $price     = $params->get('autopublish_price', '');
        } else {
            $creator   = 0;
            $desc      = '';
            $access    = $cat_access !== null ? $cat_access : 1;
            $language  = '*';
            $tags      = '';
            $price     = '';
        }

        $sha1_value = sha1_file($file_path);
        $md5_value  =  md5_file($file_path);

        // Build data array
        $data = array (
        'id' => 0,
        'catid' => $cat_id,
        'title' => $title,
        'alias' => $alias,
        'notes' => '',
        'url_download' => $filename,
        'size' => $file_size,
        'price' => $price,
        'description' => $desc,
        'description_long' => $desc,
        'changelog' => $desc,
        'file_pic' => $file_pic,
        'images' => $images,
        'created' => $creation_date,
        'file_date' => $creation_date,
        'sha1_value' => $sha1_value,
        'md5_value' => $md5_value,
        'published' => (int)$params->get('autopublish_founded_files'),
        'access' => $access,
        'metadesc' => '',
        'metakey' => '',
        'created_by' => $creator,
        'language' => $language,
        'tags' => $tags,
        'rules' => array(
            'core.create' => array(),
            'core.delete' => array(),
            'core.edit' => array(),
            'core.edit.state' => array(),
            'core.edit.own' => array(),
            'download' => array(),
        ),
        'params' => array(),
        );

        // Create download using model
        $model_download = new \JDownloads\Component\JDownloads\Administrator\Model\DownloadModel();
        $result = $model_download->save($data, true);

        if ($result) {
            return ['success' => true];
        } else {
            // Get error from model if available
            $error = $model_download->getError();
            return ['success' => false, 'error' => $error ?: 'Download creation failed'];
        }

    } catch (Throwable $e) {
        // Include file and line so a failure can be located quickly
        return ['success' => false, 'error' => $e->getMessage() . ' @ ' . basename($e->getFile()) . ':' . $e->getLine()];
    }
}

/**
 * Add log entry to temporary file
 */
function addLog(&$scanData, $message) {

    // Write to temporary log file
    if (!isset($scanData['log_file'])) {
        // Fallback: log_file not set - this should never happen
        error_log('WARNING: addLog called but log_file not set in scanData');
        return;
    }

    try {
        $log_file = $scanData['log_file'];

        // Ensure directory exists
        $dir = dirname($log_file);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        // Write log entry
        $result = file_put_contents($log_file, $message . "\n", FILE_APPEND | LOCK_EX);

        if ($result === false) {
            error_log('Failed to write to log file: ' . $log_file . ' (Message: ' . $message . ')');
        }
    } catch (Throwable $e) {
        error_log('Failed to write log entry (Throwable): ' . $e->getMessage());
    }
}

/**
 * Build a comparable category path key for download lookups.
 *
 * @param string $cat_dir_parent The parent directory path.
 * @param string $cat_dir The category directory name.
 *
 * @return string
 */
function buildCategoryPathKey($cat_dir_parent, $cat_dir) {
    if ($cat_dir_parent !== '') {
        return $cat_dir_parent . '/' . $cat_dir;
    }

    return $cat_dir;
}

/**
 * Get recent log entries for backend stats module display
 */
function getRecentLogs($log_file, $max_lines = 100) {
    if (!File::exists($log_file)) {
        return [];
    }

    try {
        $content = file_get_contents($log_file);
        if (!$content) {
            return [];
        }

        $all_lines = explode("\n", trim($content));

        // Return last $max_lines entries
        return array_slice($all_lines, -$max_lines);
    } catch (Exception $e) {
        error_log('Failed to read log file: ' . $e->getMessage());
        return [];
    }
}

// Helper function for safe JSON output
function jsonResponse($data) {
    // Clean any previous output
    if (ob_get_level() > 0) {
        ob_clean();
    }

    // Set JSON header if not already sent
    if (!headers_sent()) {
        header('Content-Type: application/json; charset=utf-8');
    }

    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

// Return current scan status
function handleStatus() {
    global $session;

    $scanData = $session->get('jd_scan_progress', null);

    if (!$scanData) {
        jsonResponse([
            'success' => false,
            'error' => 'No scan in progress'
        ]);
    }

    updateTimingData($scanData);

    // Add recent log entries for backend stats module display
    if (isset($scanData['log_file'])) {
        $scanData['log'] = getRecentLogs($scanData['log_file'], 100);
    } else {
        $scanData['log'] = [];
    }

    jsonResponse([
        'success' => true,
        'data' => $scanData
    ]);
}

// Reset scan data and delete temporary log file
function handleReset() {
    global $session;

    $scanData = $session->get('jd_scan_progress', null);

    // Delete temporary log file
    if ($scanData && isset($scanData['log_file']) && File::exists($scanData['log_file'])) {
        @unlink($scanData['log_file']);
    }

    $session->clear('jd_scan_progress');

    jsonResponse([
        'success' => true,
        'message' => 'Scan data cleared'
    ]);
}

/**
 * Update runtime and optional debug information for the current scan state.
 *
 * @param array $scanData The mutable scan state.
 *
 * @return void
 */
function updateTimingData(&$scanData) {
    if (!empty($scanData['completed']) && isset($scanData['final_duration_seconds'], $scanData['final_duration_message'], $scanData['final_debug_info_lines'])) {
        $scanData['duration_seconds'] = $scanData['final_duration_seconds'];
        $scanData['duration_message'] = $scanData['final_duration_message'];
        $scanData['debug_info_lines'] = $scanData['final_debug_info_lines'];

        return;
    }

    $duration_seconds = 0;

    if (isset($scanData['started_microtime'])) {
        $duration_seconds = max(0, microtime(true) - (float) $scanData['started_microtime']);
    }

    $formatted_duration = number_format($duration_seconds, 2, '.', '');

    $scanData['duration_seconds'] = (float) $formatted_duration;
    $scanData['duration_message'] = Text::sprintf('COM_JDOWNLOADS_AUTOCHECK_DURATION', $formatted_duration);
    $scanData['debug_info_lines'] = [];

    if (ComponentHelper::getParams('com_jdownloads')->get('view_debug_info')) {
        $scanData['debug_info_lines'][] = Text::_('COM_JDOWNLOADS_AUTOCHECK_NUMBER_OF_STARST_LABEL') . (int) ($scanData['number_of_runs'] ?? 0);
        $scanData['debug_info_lines'][] = 'max_execution_time: ' . (int) ini_get('max_execution_time');
        $scanData['debug_info_lines'][] = 'memory_limit: ' . (int) ini_get('memory_limit');
    }

    if (!empty($scanData['completed'])) {
        $scanData['final_duration_seconds'] = $scanData['duration_seconds'];
        $scanData['final_duration_message'] = $scanData['duration_message'];
        $scanData['final_debug_info_lines'] = $scanData['debug_info_lines'];
    }
}

/**
 * Export complete log for download (not limited to 100 entries)
 */
function handleExportLog() {
    global $session;

    $scanData = $session->get('jd_scan_progress', null);
    $log_file = null;

    // Try to get log file from session
    if ($scanData && isset($scanData['log_file'])) {
        $log_file = $scanData['log_file'];
    }

    // Fallback: Find most recent log file if session data not available
    if (!$log_file || !File::exists($log_file)) {
        $tmp_dir = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, JPATH_BASE) . DIRECTORY_SEPARATOR . 'tmp';
        $pattern = $tmp_dir . DIRECTORY_SEPARATOR . 'jd_scan_*.log';
        $files = glob($pattern);

        if ($files && count($files) > 0) {
            // Sort by modification time, newest first
            usort($files, function($a, $b) {
                return filemtime($b) - filemtime($a);
            });
            $log_file = $files[0]; // Use newest file
        }
    }

    // Still no log file found
    if (!$log_file || !File::exists($log_file)) {
        jsonResponse([
            'success' => false,
            'error' => 'No log file available',
            'debug' => [
                'session_has_data' => ($scanData !== null),
                'session_has_log_file' => isset($scanData['log_file']),
                'log_file_path' => $log_file
            ]
        ]);
    }

    // Read ALL log entries from temp file (not limited)
    $all_logs = [];
    try {
        $content = file_get_contents($log_file);
        if ($content) {
            $all_logs = explode("\n", trim($content));
            // Remove empty lines
            $all_logs = array_filter($all_logs, function($line) {
                return !empty(trim($line));
            });
            // Re-index array
            $all_logs = array_values($all_logs);
        }
    } catch (Exception $e) {
        jsonResponse([
            'success' => false,
            'error' => 'Failed to read log file: ' . $e->getMessage()
        ]);
    }

    if ($scanData) {
        if (!empty($scanData['duration_message'])) {
            $all_logs[] = $scanData['duration_message'];
        }

        if (!empty($scanData['debug_info_lines'])) {
            foreach ($scanData['debug_info_lines'] as $debug_info_line) {
                $all_logs[] = $debug_info_line;
            }
        }
    }

    jsonResponse([
        'success' => true,
        'log' => $all_logs,
        'total' => count($all_logs)
    ]);
}

/**
 * Save scan results to monitoring_logs.txt for backend display
 * This matches the behavior of scan.php
 */
function saveMonitoringLog($scanData, $testrun) {
    $params = ComponentHelper::getParams('com_jdownloads');
    $log_file = JPATH_BASE . '/administrator/components/com_jdownloads/monitoring_logs.txt';

    // Only save if log_save is enabled
    if (!isset($scanData['log_save']) || !$scanData['log_save']) {
        return;
    }

    // Read all logs from temporary file
    $all_logs = [];
    if (isset($scanData['log_file']) && File::exists($scanData['log_file'])) {
        $content = file_get_contents($scanData['log_file']);
        if ($content) {
            $all_logs = explode("\n", trim($content));
        }
    }

    // Build log message with HTML formatting (same as scan.php)
    $log_message = '';

    if (count($all_logs) > 0) {
        $date = date(Text::_('DATE_FORMAT_LC2')) . ':<br />';

        if ($testrun) {
            $date .= '<big><b>' . Text::_('COM_JDOWNLOADS_AUTO_CHECK_TEST_RUN_HINT') . '</b></big><br />';
        } else {
            $date .= '<big><b>' . Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_LOG_TITLE') . '</b></big><br />';
        }

        $log_message = $date;

        // Convert log array entries to HTML
        foreach ($all_logs as $logEntry) {
            if (empty($logEntry)) continue;

            // Add HTML formatting based on log entry content
            if (strpos($logEntry, '✓') !== false) {
                $log_message .= '<span style="color:green;">' . htmlspecialchars($logEntry) . '</span><br />';
            } elseif (strpos($logEntry, '✗') !== false || strpos($logEntry, 'ERROR') !== false) {
                $log_message .= '<span style="color:red;"><b>' . htmlspecialchars($logEntry) . '</b></span><br />';
            } else {
                $log_message .= htmlspecialchars($logEntry) . '<br />';
            }
        }

        // Add statistics summary
        if (isset($scanData['stats'])) {
            $log_message .= '<br /><b>' . Text::_('COM_JDOWNLOADS_BACKEND_AUTOCHECK_TITLE') . '</b><br />';

            if (isset($scanData['stats']['new_cats'])) {
                $log_message .= Text::_('COM_JDOWNLOADS_MONITORING_NEW_CATS') . ': <b>' . $scanData['stats']['new_cats'] . '</b><br />';
            }
            if (isset($scanData['stats']['new_downloads'])) {
                $log_message .= Text::_('COM_JDOWNLOADS_MONITORING_NEW_DOWNLOADS') . ': <b>' . $scanData['stats']['new_downloads'] . '</b><br />';
            }
            if (isset($scanData['stats']['missing_cats'])) {
                $log_message .= Text::_('COM_JDOWNLOADS_MONITORING_MISSING_CATS') . ': <b>' . $scanData['stats']['missing_cats'] . '</b><br />';
            }
            if (isset($scanData['stats']['missing_files'])) {
                $log_message .= Text::_('COM_JDOWNLOADS_MONITORING_MISSING_FILES') . ': <b>' . $scanData['stats']['missing_files'] . '</b><br />';
            }
        }

        if (!empty($scanData['duration_message'])) {
            $log_message .= '<br />' . htmlspecialchars($scanData['duration_message']) . '<br />';
        }

        if (!empty($scanData['debug_info_lines'])) {
            foreach ($scanData['debug_info_lines'] as $debug_info_line) {
                $log_message .= htmlspecialchars($debug_info_line) . '<br />';
            }
        }

        $log_message .= '<br /><hr /><br />';
    }

    // Write to file (prepend new log to existing content)
    if ($log_message != '') {
        try {
            if (File::exists($log_file)) {
                // Check file size limit
                $size = (int)filesize($log_file);
                $max_size_kb = $params->get('max_size_log_file', 32);

                if (($size / 1024) >= $max_size_kb) {
                    // File too large, delete and start fresh
                    @unlink($log_file);
                    file_put_contents($log_file, $log_message, FILE_APPEND | LOCK_EX);
                } else {
                    // Prepend new log to existing content
                    $content = file_get_contents($log_file);
                    $new_content = $log_message . $content;
                    file_put_contents($log_file, $new_content, LOCK_EX);
                }
            } else {
                // Create new file
                file_put_contents($log_file, $log_message, FILE_APPEND | LOCK_EX);
            }
        } catch (Exception $e) {
            // Silently fail - logging should not break the scan
            error_log('Failed to write monitoring log: ' . $e->getMessage());
        }
    }

    // Note: Temporary log file is NOT deleted here - it remains available
    // for real-time display in the modal. It will be cleaned up by:
    // 1. Next scan start (handleStart clears old files)
    // 2. Periodic cleanup job (optional)
}

// Helper to get formatted date
function getFormattedDate() {
    // Create date string
    $date = Factory::getDate();
    $tz = Factory::getConfig()->get( 'offset' );
    $date->setTimezone(new DateTimeZone($tz));
    $date = date(Text::_('DATE_FORMAT_LC2'));

    return $date;
}
