<?php
// Complete application and network backup service.
// The web page, CLI worker and installer-scheduled task all use this file.

require_once __DIR__ . '/app_paths.php';
require_once __DIR__ . '/db_schema_helpers.php';
require_once __DIR__ . '/mikrotik_connection_lib.php';

if (!function_exists('ua_backup_ensure_schema')) {
  function ua_backup_ensure_schema(mysqli $conn): void {
    @$conn->query("CREATE TABLE IF NOT EXISTS ua_backup_settings (
      id TINYINT UNSIGNED NOT NULL PRIMARY KEY DEFAULT 1,
      enabled TINYINT(1) NOT NULL DEFAULT 0,
      frequency VARCHAR(16) NOT NULL DEFAULT 'daily',
      run_time CHAR(5) NOT NULL DEFAULT '02:00',
      week_day TINYINT UNSIGNED NOT NULL DEFAULT 1,
      month_day TINYINT UNSIGNED NOT NULL DEFAULT 1,
      destination_path VARCHAR(1000) NOT NULL DEFAULT '',
      include_files TINYINT(1) NOT NULL DEFAULT 1,
      include_routers TINYINT(1) NOT NULL DEFAULT 1,
      retention_count SMALLINT UNSIGNED NOT NULL DEFAULT 12,
      last_run_at DATETIME NULL,
      last_success_at DATETIME NULL,
      updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");

    @$conn->query("CREATE TABLE IF NOT EXISTS ua_backup_runs (
      id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
      trigger_type VARCHAR(16) NOT NULL DEFAULT 'manual',
      status VARCHAR(16) NOT NULL DEFAULT 'running',
      started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
      finished_at DATETIME NULL,
      file_name VARCHAR(255) NULL,
      file_path VARCHAR(1200) NULL,
      bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
      checksum_sha256 CHAR(64) NULL,
      database_tables INT UNSIGNED NOT NULL DEFAULT 0,
      database_rows BIGINT UNSIGNED NOT NULL DEFAULT 0,
      included_files INT UNSIGNED NOT NULL DEFAULT 0,
      router_success INT UNSIGNED NOT NULL DEFAULT 0,
      router_failed INT UNSIGNED NOT NULL DEFAULT 0,
      warnings_json LONGTEXT NULL,
      error_text TEXT NULL,
      INDEX idx_backup_status_started (status, started_at),
      INDEX idx_backup_finished (finished_at)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");

    $default = ua_backup_default_destination();
    $esc = $conn->real_escape_string($default);
    @$conn->query("INSERT IGNORE INTO ua_backup_settings (id,destination_path) VALUES (1,'{$esc}')");
  }
}

if (!function_exists('ua_backup_default_destination')) {
  function ua_backup_default_destination(): string {
    $drive = rtrim((string)(getenv('SystemDrive') ?: 'C:'), "\\/");
    return $drive . DIRECTORY_SEPARATOR . 'NetworkCustomersBackups';
  }
}

if (!function_exists('ua_backup_settings')) {
  function ua_backup_settings(mysqli $conn): array {
    ua_backup_ensure_schema($conn);
    $row = null;
    $rs = @$conn->query('SELECT * FROM ua_backup_settings WHERE id=1 LIMIT 1');
    if ($rs) $row = $rs->fetch_assoc();
    return array_replace([
      'enabled'=>0, 'frequency'=>'daily', 'run_time'=>'02:00', 'week_day'=>1,
      'month_day'=>1, 'destination_path'=>ua_backup_default_destination(),
      'include_files'=>1, 'include_routers'=>1, 'retention_count'=>12,
      'last_run_at'=>null, 'last_success_at'=>null,
    ], is_array($row) ? $row : []);
  }
}

if (!function_exists('ua_backup_normalize_destination')) {
  function ua_backup_normalize_destination(string $path, bool $create = true): array {
    $path = trim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path));
    if ($path === '') $path = ua_backup_default_destination();
    if (strpos($path, "\0") !== false || preg_match('~^[a-z][a-z0-9+.-]*:/{2}~i', $path)) {
      return ['ok'=>false, 'path'=>'', 'error'=>'مسار الحفظ غير صالح.'];
    }
    $absolute = (bool)preg_match('~^[A-Za-z]:\\\\~', $path) || str_starts_with($path, '\\\\') || str_starts_with($path, DIRECTORY_SEPARATOR);
    if (!$absolute) return ['ok'=>false, 'path'=>'', 'error'=>'يجب اختيار مسار كامل مثل C:\\NetworkCustomersBackups.'];
    $path = rtrim($path, "\\/");
    if ($create && !is_dir($path) && !@mkdir($path, 0770, true) && !is_dir($path)) {
      return ['ok'=>false, 'path'=>'', 'error'=>'تعذر إنشاء مجلد الحفظ المحدد.'];
    }
    if (!is_dir($path) || !is_writable($path)) {
      return ['ok'=>false, 'path'=>'', 'error'=>'مجلد الحفظ غير موجود أو لا يملك البرنامج صلاحية الكتابة داخله.'];
    }
    $real = realpath($path);
    return ['ok'=>true, 'path'=>$real !== false ? $real : $path, 'error'=>''];
  }
}

if (!function_exists('ua_backup_save_settings')) {
  function ua_backup_save_settings(mysqli $conn, array $input): array {
    ua_backup_ensure_schema($conn);
    $frequency = strtolower(trim((string)($input['frequency'] ?? 'daily')));
    if (!in_array($frequency, ['daily','weekly','monthly'], true)) $frequency = 'daily';
    $time = trim((string)($input['run_time'] ?? '02:00'));
    if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)) {
      return ['ok'=>false,'error'=>'وقت التنفيذ غير صالح.'];
    }
    $destination = ua_backup_normalize_destination((string)($input['destination_path'] ?? ''), true);
    if (!$destination['ok']) return $destination;
    $weekDay = max(1, min(7, (int)($input['week_day'] ?? 1)));
    $monthDay = max(1, min(28, (int)($input['month_day'] ?? 1)));
    $retention = max(2, min(120, (int)($input['retention_count'] ?? 12)));
    $enabled = !empty($input['enabled']) ? 1 : 0;
    $includeFiles = !empty($input['include_files']) ? 1 : 0;
    $includeRouters = !empty($input['include_routers']) ? 1 : 0;
    $f = $conn->real_escape_string($frequency);
    $t = $conn->real_escape_string($time);
    $d = $conn->real_escape_string((string)$destination['path']);
    $ok = @$conn->query("UPDATE ua_backup_settings SET enabled={$enabled},frequency='{$f}',run_time='{$t}',week_day={$weekDay},month_day={$monthDay},destination_path='{$d}',include_files={$includeFiles},include_routers={$includeRouters},retention_count={$retention} WHERE id=1");
    return ['ok'=>(bool)$ok,'error'=>$ok?'':'تعذر حفظ إعدادات النسخ الاحتياطي.'];
  }
}

if (!function_exists('ua_backup_next_run')) {
  function ua_backup_next_run(array $settings, ?DateTimeImmutable $from = null): ?DateTimeImmutable {
    if (empty($settings['enabled'])) return null;
    $tz = new DateTimeZone(date_default_timezone_get());
    $from = $from ?: new DateTimeImmutable('now', $tz);
    [$hour,$minute] = array_map('intval', explode(':', (string)($settings['run_time'] ?? '02:00')) + [0,0]);
    $last = !empty($settings['last_run_at']) ? new DateTimeImmutable((string)$settings['last_run_at'], $tz) : null;
    $reference = ($last && $last > $from) ? $last : $from;
    $frequency = (string)($settings['frequency'] ?? 'daily');
    if ($frequency === 'weekly') {
      $day = max(1,min(7,(int)($settings['week_day'] ?? 1)));
      $candidate = $reference->modify('monday this week')->setTime($hour,$minute,0)->modify('+' . ($day - 1) . ' days');
      if ($candidate <= $reference) $candidate = $candidate->modify('+7 days');
      return $candidate;
    }
    if ($frequency === 'monthly') {
      $day = max(1,min(28,(int)($settings['month_day'] ?? 1)));
      $candidate = $reference->setDate((int)$reference->format('Y'),(int)$reference->format('m'),$day)->setTime($hour,$minute,0);
      if ($candidate <= $reference) {
        $nextMonth = $reference->modify('first day of next month');
        $candidate = $nextMonth->setDate((int)$nextMonth->format('Y'),(int)$nextMonth->format('m'),$day)->setTime($hour,$minute,0);
      }
      return $candidate;
    }
    $candidate = $reference->setTime($hour,$minute,0);
    if ($candidate <= $reference) $candidate = $candidate->modify('+1 day');
    return $candidate;
  }
}

if (!function_exists('ua_backup_is_due')) {
  function ua_backup_is_due(array $settings, ?DateTimeImmutable $now = null): bool {
    if (empty($settings['enabled'])) return false;
    $tz = new DateTimeZone(date_default_timezone_get());
    $now = $now ?: new DateTimeImmutable('now', $tz);
    [$hour,$minute] = array_map('intval', explode(':', (string)($settings['run_time'] ?? '02:00')) + [0,0]);
    $frequency = (string)($settings['frequency'] ?? 'daily');
    if ($frequency === 'weekly' && (int)$now->format('N') !== max(1,min(7,(int)$settings['week_day']))) return false;
    if ($frequency === 'monthly' && (int)$now->format('j') !== max(1,min(28,(int)$settings['month_day']))) return false;
    $scheduled = $now->setTime($hour,$minute,0);
    if ($now < $scheduled) return false;
    $last = !empty($settings['last_run_at']) ? new DateTimeImmutable((string)$settings['last_run_at'], $tz) : null;
    if (!$last) return true;
    if ($frequency === 'weekly') return $last < $scheduled->modify('-6 days');
    if ($frequency === 'monthly') return $last->format('Y-m') !== $now->format('Y-m');
    return $last->format('Y-m-d') !== $now->format('Y-m-d');
  }
}

if (!function_exists('ua_backup_sql_literal')) {
  function ua_backup_sql_literal(mysqli $conn, $value): string {
    if ($value === null) return 'NULL';
    return "'" . $conn->real_escape_string((string)$value) . "'";
  }
}

if (!function_exists('ua_backup_export_database')) {
  function ua_backup_export_database(mysqli $conn, string $target): array {
    $fh = @fopen($target, 'wb');
    if (!$fh) throw new RuntimeException('تعذر إنشاء ملف قاعدة البيانات داخل مساحة العمل المؤقتة.');
    $tables = []; $views = []; $rowTotal = 0;
    fwrite($fh, "-- Network Customers complete database backup\n-- Generated: " . date('c') . "\nSET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n");
    $rs = @$conn->query('SHOW FULL TABLES');
    if (!$rs) { fclose($fh); throw new RuntimeException('تعذر قراءة قائمة جداول قاعدة البيانات.'); }
    while ($row = $rs->fetch_array()) {
      $name = (string)$row[0]; $type = strtoupper((string)($row[1] ?? 'BASE TABLE'));
      if ($type === 'VIEW') $views[] = $name; else $tables[] = $name;
    }
    @$conn->query('START TRANSACTION WITH CONSISTENT SNAPSHOT');
    try {
      foreach ($tables as $table) {
        $safe = str_replace('`','',$table);
        $createRs = @$conn->query("SHOW CREATE TABLE `{$safe}`");
        $create = $createRs ? $createRs->fetch_assoc() : null;
        $createSql = is_array($create) ? (string)($create['Create Table'] ?? array_values($create)[1] ?? '') : '';
        fwrite($fh, "-- Table: `{$safe}`\nDROP TABLE IF EXISTS `{$safe}`;\n{$createSql};\n\n");
        $data = @$conn->query("SELECT * FROM `{$safe}`", MYSQLI_USE_RESULT);
        if (!$data) continue;
        while ($row = $data->fetch_assoc()) {
          $columns = array_map(static fn($c) => '`' . str_replace('`','',(string)$c) . '`', array_keys($row));
          $values = array_map(static fn($v) => ua_backup_sql_literal($conn,$v), array_values($row));
          fwrite($fh, "INSERT INTO `{$safe}` (" . implode(',',$columns) . ') VALUES (' . implode(',',$values) . ");\n");
          $rowTotal++;
        }
        $data->free();
        fwrite($fh, "\n");
      }
      foreach ($views as $view) {
        $safe = str_replace('`','',$view);
        $createRs = @$conn->query("SHOW CREATE VIEW `{$safe}`");
        $create = $createRs ? $createRs->fetch_assoc() : null;
        $createSql = is_array($create) ? (string)($create['Create View'] ?? '') : '';
        if ($createSql !== '') fwrite($fh, "DROP VIEW IF EXISTS `{$safe}`;\n{$createSql};\n\n");
      }
      @$conn->query('COMMIT');
    } catch (Throwable $e) {
      @$conn->query('ROLLBACK'); fclose($fh); throw $e;
    }
    fwrite($fh, "SET FOREIGN_KEY_CHECKS=1;\n");
    fclose($fh);
    return ['tables'=>count($tables) + count($views), 'rows'=>$rowTotal, 'bytes'=>(int)@filesize($target)];
  }
}

if (!function_exists('ua_backup_safe_name')) {
  function ua_backup_safe_name(string $name): string {
    $name = preg_replace('/[^A-Za-z0-9_.-]+/', '-', trim($name));
    return trim((string)$name, '-_.') ?: 'device';
  }
}

if (!function_exists('ua_backup_router_snapshot')) {
  function ua_backup_router_snapshot(array $router, string $directory): array {
    $label = ua_mt_router_label($router);
    $error = ''; $api = ua_mt_connect_config($router, $error, 1.2);
    if (!$api) return ['ok'=>false,'label'=>$label,'files'=>0,'error'=>$error ?: 'تعذر الاتصال.'];
    $base = ua_backup_safe_name((string)($router['name'] ?? 'router') . '-' . (string)($router['id'] ?? 0));
    $sections = [
      'system_identity'=>'/system/identity/print','system_resource'=>'/system/resource/print',
      'interfaces'=>'/interface/print','bridges'=>'/interface/bridge/print','bridge_ports'=>'/interface/bridge/port/print',
      'vlans'=>'/interface/vlan/print','ip_addresses'=>'/ip/address/print','ip_routes'=>'/ip/route/print',
      'dns'=>'/ip/dns/print','pools'=>'/ip/pool/print','dhcp_servers'=>'/ip/dhcp-server/print',
      'ppp_profiles'=>'/ppp/profile/print','ppp_secrets'=>'/ppp/secret/print','simple_queues'=>'/queue/simple/print',
      'firewall_filters'=>'/ip/firewall/filter/print','firewall_nat'=>'/ip/firewall/nat/print','firewall_mangle'=>'/ip/firewall/mangle/print',
      'services'=>'/ip/service/print','users'=>'/user/print','schedulers'=>'/system/scheduler/print','scripts'=>'/system/script/print',
    ];
    $snapshot = ['captured_at'=>date('c'),'router'=>['id'=>(int)($router['id']??0),'name'=>(string)($router['name']??''),'host'=>(string)($router['host']??''),'role'=>(string)($router['role']??'')],'sections'=>[],'section_errors'=>[]];
    foreach ($sections as $key=>$path) {
      try { $result = ua_mt_call($api,$path,[]); if (is_array($result)) $snapshot['sections'][$key] = $result; else $snapshot['section_errors'][$key] = 'empty'; }
      catch (Throwable $e) { $snapshot['section_errors'][$key] = $e->getMessage(); }
    }
    $jsonFile = $directory . DIRECTORY_SEPARATOR . $base . '.routeros.json';
    @file_put_contents($jsonFile, json_encode($snapshot, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT), LOCK_EX);
    $files = is_file($jsonFile) ? 1 : 0;
    try {
      $remote = 'networkcustomers-' . date('Ymd-His') . '-' . substr(hash('sha256',$label),0,8);
      if (method_exists($api,'command')) {
        $api->command(['/export','=file='.$remote,'=show-sensitive=yes']);
        usleep(350000);
        $rows = $api->command(['/file/print','?name='.$remote.'.rsc','=.proplist=.id,name,contents,size']);
        $content = is_array($rows) ? (string)($rows[0]['contents'] ?? '') : '';
        if ($content !== '') { @file_put_contents($directory . DIRECTORY_SEPARATOR . $base . '.rsc',$content,LOCK_EX); $files++; }
        if (is_array($rows) && !empty($rows[0]['.id'])) $api->command(['/file/remove','=.id='.(string)$rows[0]['.id']]);
      }
    } catch (Throwable $e) { $snapshot['section_errors']['rsc_export'] = $e->getMessage(); }
    ua_mt_safe_disconnect($api);
    return ['ok'=>$files>0,'label'=>$label,'files'=>$files,'error'=>$files>0?'':'تعذر حفظ لقطة الجهاز.'];
  }
}

if (!function_exists('ua_backup_copy_tree')) {
  function ua_backup_copy_tree(string $source, string $destination, array &$stats): void {
    if (!is_dir($source)) return;
    if (!is_dir($destination)) @mkdir($destination,0770,true);
    $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST);
    foreach ($iterator as $item) {
      $relative = substr($item->getPathname(), strlen($source) + 1);
      if ($relative === false || $relative === '') continue;
      $parts = preg_split('~[\\\\/]~',$relative) ?: [];
      $excluded = false;
      foreach ($parts as $part) {
        $part = strtolower((string)$part);
        if (in_array($part, ['backup_work','backup_runtime','backups','generated'], true)
            || str_starts_with($part, 'qa-')) {
          $excluded = true;
          break;
        }
      }
      if ($excluded) continue;
      $target = $destination . DIRECTORY_SEPARATOR . $relative;
      if ($item->isDir()) { if (!is_dir($target)) @mkdir($target,0770,true); continue; }
      if (!$item->isFile()) continue;
      if (!is_dir(dirname($target))) @mkdir(dirname($target),0770,true);
      if (@copy($item->getPathname(),$target)) { $stats['files']++; $stats['bytes'] += (int)$item->getSize(); }
    }
  }
}

if (!function_exists('ua_backup_remove_tree')) {
  function ua_backup_remove_tree(string $directory): void {
    if (!is_dir($directory)) return;
    $root = realpath($directory); $workspace = realpath(ua_project_path('storage' . DIRECTORY_SEPARATOR . 'backup_work'));
    if ($root === false || $workspace === false || !str_starts_with(strtolower($root), strtolower($workspace . DIRECTORY_SEPARATOR))) return;
    $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST);
    foreach ($it as $item) { $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); }
    @rmdir($root);
  }
}

if (!function_exists('ua_backup_create_archive')) {
  function ua_backup_create_archive(string $sourceDir, string $targetTar): int {
    if (is_file($targetTar)) @unlink($targetTar);
    $systemTar = getenv('SystemRoot') ? rtrim((string)getenv('SystemRoot'), '\\/') . DIRECTORY_SEPARATOR . 'System32' . DIRECTORY_SEPARATOR . 'tar.exe' : '';
    if ($systemTar !== '' && is_file($systemTar) && function_exists('proc_open')) {
      $pipes = [];
      $process = @proc_open([$systemTar, '-cf', $targetTar, '-C', $sourceDir, '.'], [
        0 => ['pipe', 'r'],
        1 => ['pipe', 'w'],
        2 => ['pipe', 'w'],
      ], $pipes, $sourceDir, null, ['bypass_shell' => true]);
      if (is_resource($process)) {
        @fclose($pipes[0]);
        $stdout = stream_get_contents($pipes[1]);
        $stderr = stream_get_contents($pipes[2]);
        @fclose($pipes[1]);
        @fclose($pipes[2]);
        $exitCode = proc_close($process);
        clearstatcache(true, $targetTar);
        $bytes = (int)@filesize($targetTar);
        if ($exitCode === 0 && $bytes > 0) return $bytes;
        if (is_file($targetTar)) @unlink($targetTar);
        throw new RuntimeException('تعذر إنشاء حزمة النسخة الاحتياطية بواسطة محرك النظام: ' . trim((string)($stderr ?: $stdout)));
      }
    }
    $archive = new PharData($targetTar, 0, null, Phar::TAR);
    $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($sourceDir,FilesystemIterator::SKIP_DOTS));
    foreach ($it as $item) {
      if (!$item->isFile()) continue;
      $relative = str_replace('\\','/',substr($item->getPathname(),strlen($sourceDir)+1));
      $archive->addFile($item->getPathname(),$relative);
    }
    unset($archive);
    clearstatcache(true,$targetTar);
    return (int)@filesize($targetTar);
  }
}

if (!function_exists('ua_backup_apply_retention')) {
  function ua_backup_apply_retention(mysqli $conn, int $keep): void {
    $keep = max(2,min(120,$keep));
    $rs = @$conn->query("SELECT id,file_path FROM ua_backup_runs WHERE status='success' ORDER BY id DESC");
    $index = 0;
    if ($rs) while ($row = $rs->fetch_assoc()) {
      $index++; if ($index <= $keep) continue;
      $path = (string)($row['file_path'] ?? '');
      if ($path !== '' && is_file($path)) @unlink($path);
      @$conn->query('DELETE FROM ua_backup_runs WHERE id='.(int)$row['id']);
    }
  }
}

if (!function_exists('ua_backup_run')) {
  function ua_backup_run(mysqli $conn, string $trigger = 'manual'): array {
    ua_backup_ensure_schema($conn);
    $settings = ua_backup_settings($conn);
    $destination = ua_backup_normalize_destination((string)$settings['destination_path'],true);
    if (!$destination['ok']) return ['ok'=>false,'error'=>$destination['error']];
    $runtime = ua_project_path('storage' . DIRECTORY_SEPARATOR . 'backup_runtime');
    $workspaceRoot = ua_project_path('storage' . DIRECTORY_SEPARATOR . 'backup_work');
    if (!is_dir($runtime)) @mkdir($runtime,0770,true);
    if (!is_dir($workspaceRoot)) @mkdir($workspaceRoot,0770,true);
    $lockHandle = @fopen($runtime . DIRECTORY_SEPARATOR . 'backup.lock','c+');
    if (!$lockHandle || !@flock($lockHandle,LOCK_EX|LOCK_NB)) return ['ok'=>false,'error'=>'توجد عملية نسخ احتياطي أخرى قيد التنفيذ.'];
    // The exclusive lock proves that older "running" rows belong to an
    // interrupted process; closing them here keeps the history truthful.
    @$conn->query("UPDATE ua_backup_runs SET status='failed',finished_at=NOW(),error_text='انقطع تنفيذ النسخة الاحتياطية قبل اكتمالها.' WHERE status='running'");
    $trigger = in_array($trigger,['manual','scheduled'],true) ? $trigger : 'manual';
    $tr = $conn->real_escape_string($trigger);
    @$conn->query("INSERT INTO ua_backup_runs(trigger_type,status,started_at) VALUES('{$tr}','running',NOW())");
    $runId = (int)$conn->insert_id;
    $work = $workspaceRoot . DIRECTORY_SEPARATOR . 'run-' . $runId . '-' . bin2hex(random_bytes(4));
    @mkdir($work,0770,true);
    $warnings=[]; $fileStats=['files'=>0,'bytes'=>0]; $routerOk=0; $routerFail=0; $target='';
    try {
      $dbStats = ua_backup_export_database($conn,$work . DIRECTORY_SEPARATOR . 'database.sql');
      if (!empty($settings['include_files'])) {
        $sources = [
          'admin/uploads'=>ua_admin_path('uploads'),
          'admin/assets/id_cards'=>ua_admin_path('assets' . DIRECTORY_SEPARATOR . 'id_cards'),
          'storage'=>ua_project_path('storage'),
        ];
        foreach ($sources as $archivePath=>$source) ua_backup_copy_tree($source,$work . DIRECTORY_SEPARATOR . 'files' . DIRECTORY_SEPARATOR . str_replace('/' , DIRECTORY_SEPARATOR,$archivePath),$fileStats);
        foreach (['install_config.php','config.php'] as $file) {
          $src=ua_project_path($file); if (is_file($src)) { $dst=$work.DIRECTORY_SEPARATOR.'files'.DIRECTORY_SEPARATOR.$file; if (!is_dir(dirname($dst))) @mkdir(dirname($dst),0770,true); if (@copy($src,$dst)) {$fileStats['files']++;$fileStats['bytes']+=(int)filesize($src);} }
        }
      }
      if (!empty($settings['include_routers'])) {
        $routerDir=$work.DIRECTORY_SEPARATOR.'routers'; @mkdir($routerDir,0770,true);
        $routers=[]; $rs=@$conn->query("SELECT * FROM routers WHERE COALESCE(is_enabled,1)=1 ORDER BY id ASC"); if($rs)while($r=$rs->fetch_assoc())$routers[]=$r;
        if (!$routers) { $rs=@$conn->query('SELECT * FROM mikrotik_settings ORDER BY id ASC'); if($rs)while($r=$rs->fetch_assoc()){$r['name']=$r['name']??'MikroTik';$routers[]=$r;} }
        foreach ($routers as $router) { $result=ua_backup_router_snapshot($router,$routerDir); if(!empty($result['ok']))$routerOk++;else{$routerFail++;$warnings[]='فشل نسخ '.$result['label'].': '.$result['error'];} }
      }
      $manifest=[
        'format'=>'NetworkCustomersBackup','version'=>1,'created_at'=>date('c'),'application_version'=>defined('APP_VERSION')?APP_VERSION:null,
        'database'=>$dbStats,'files'=>$fileStats,'routers'=>['success'=>$routerOk,'failed'=>$routerFail],
        'contents'=>['database'=>'database.sql','user_files'=>!empty($settings['include_files']),'router_snapshots'=>!empty($settings['include_routers'])],
        'warnings'=>$warnings,
      ];
      @file_put_contents($work.DIRECTORY_SEPARATOR.'manifest.json',json_encode($manifest,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT),LOCK_EX);
      $fileName='NetworkCustomers-'.date('Ymd-His').'-'.$runId.'.ncbak.tar';
      $target=(string)$destination['path'].DIRECTORY_SEPARATOR.$fileName;
      $bytes=ua_backup_create_archive($work,$target);
      if ($bytes <= 0 || !is_file($target)) throw new RuntimeException('تعذر إنشاء حزمة النسخة الاحتياطية.');
      $checksum=hash_file('sha256',$target);
      $f=$conn->real_escape_string($fileName);$p=$conn->real_escape_string($target);$c=$conn->real_escape_string($checksum);$w=$conn->real_escape_string(json_encode($warnings,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
      @$conn->query("UPDATE ua_backup_runs SET status='success',finished_at=NOW(),file_name='{$f}',file_path='{$p}',bytes={$bytes},checksum_sha256='{$c}',database_tables=".(int)$dbStats['tables'].",database_rows=".(int)$dbStats['rows'].",included_files=".(int)$fileStats['files'].",router_success={$routerOk},router_failed={$routerFail},warnings_json='{$w}' WHERE id={$runId}");
      @$conn->query("UPDATE ua_backup_settings SET last_run_at=NOW(),last_success_at=NOW() WHERE id=1");
      ua_backup_apply_retention($conn,(int)$settings['retention_count']);
      ua_backup_remove_tree($work);
      @flock($lockHandle,LOCK_UN);@fclose($lockHandle);
      return ['ok'=>true,'run_id'=>$runId,'file'=>$target,'name'=>$fileName,'bytes'=>$bytes,'checksum'=>$checksum,'database'=>$dbStats,'files'=>$fileStats,'routers'=>['success'=>$routerOk,'failed'=>$routerFail],'warnings'=>$warnings];
    } catch (Throwable $e) {
      if ($target !== '' && is_file($target)) @unlink($target);
      $message=$e->getMessage();$esc=$conn->real_escape_string($message);$w=$conn->real_escape_string(json_encode($warnings,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
      @$conn->query("UPDATE ua_backup_runs SET status='failed',finished_at=NOW(),router_success={$routerOk},router_failed={$routerFail},warnings_json='{$w}',error_text='{$esc}' WHERE id={$runId}");
      @$conn->query("UPDATE ua_backup_settings SET last_run_at=NOW() WHERE id=1");
      ua_backup_remove_tree($work);@flock($lockHandle,LOCK_UN);@fclose($lockHandle);
      return ['ok'=>false,'run_id'=>$runId,'error'=>$message,'warnings'=>$warnings];
    }
  }
}

if (!function_exists('ua_backup_runs')) {
  function ua_backup_runs(mysqli $conn, int $limit=40): array {
    ua_backup_ensure_schema($conn);$limit=max(1,min(200,$limit));$rows=[];
    $rs=@$conn->query("SELECT * FROM ua_backup_runs ORDER BY id DESC LIMIT {$limit}"); if($rs)while($r=$rs->fetch_assoc())$rows[]=$r;return $rows;
  }
}

if (!function_exists('ua_backup_find_run')) {
  function ua_backup_find_run(mysqli $conn, int $id): ?array {
    ua_backup_ensure_schema($conn);$rs=@$conn->query('SELECT * FROM ua_backup_runs WHERE id='.(int)$id.' LIMIT 1');$row=$rs?$rs->fetch_assoc():null;return is_array($row)?$row:null;
  }
}

if (!function_exists('ua_backup_verify_run')) {
  function ua_backup_verify_run(mysqli $conn, int $id): array {
    $run=ua_backup_find_run($conn,$id);if(!$run)return['ok'=>false,'error'=>'النسخة غير موجودة في السجل.'];
    $path=(string)($run['file_path']??'');if($path===''||!is_file($path))return['ok'=>false,'error'=>'ملف النسخة غير موجود في موقع الحفظ.'];
    $actual=hash_file('sha256',$path);$expected=(string)($run['checksum_sha256']??'');
    return ['ok'=>$expected!==''&&hash_equals($expected,$actual),'actual'=>$actual,'expected'=>$expected,'error'=>($expected!==''&&hash_equals($expected,$actual))?'':'بصمة الملف لا تطابق السجل؛ قد يكون الملف تالفاً أو معدلاً.'];
  }
}

if (!function_exists('ua_backup_delete_run')) {
  function ua_backup_delete_run(mysqli $conn, int $id): array {
    $run=ua_backup_find_run($conn,$id);if(!$run)return['ok'=>false,'error'=>'السجل غير موجود.'];
    $path=(string)($run['file_path']??'');if($path!==''&&is_file($path)&&!@unlink($path))return['ok'=>false,'error'=>'تعذر حذف الملف من موقع الحفظ.'];
    @$conn->query('DELETE FROM ua_backup_runs WHERE id='.(int)$id);return['ok'=>true,'error'=>''];
  }
}
