<?php

namespace SHServ\Tools;

/**
 * Каталог прошивок OTA.
 *
 * Сканирует директорию с ZIP-архивами, извлекает manifest.json,
 * кэширует метаданные в памяти и предоставляет поиск по совместимости.
 */
class FirmwareCatalog {

	private static ?array $cache = null;

	private string $firmwaresDir;
	private string $tempBaseDir;

	public function __construct() {
		$this -> firmwaresDir = FCONF['firmwares_dir'] ?? dirname(__DIR__, 2) . '/firmwares';
		$this -> tempBaseDir  = sys_get_temp_dir() . '/shserv_firmwares';

		if (!is_dir($this -> firmwaresDir)) {
			@mkdir($this -> firmwaresDir, 0777, true);
		}
	}

	/**
	 * Принудительно пересканировать директорию с прошивками.
	 *
	 * @return array Ассоциативный массив [id => entry]
	 */
	public function scan(): array {
		self::$cache = [];

		if (!is_dir($this -> firmwaresDir)) {
			return [];
		}

		$files = glob($this -> firmwaresDir . '/*.zip');
		if (!$files) {
			return [];
		}

		foreach ($files as $file) {
			$manifest = $this -> extractManifest($file);
			if ($manifest !== null) {
				self::$cache[$manifest['id']] = [
					'manifest' => $manifest,
					'zip_path' => $file,
				];
			}
		}

		return self::$cache;
	}

	/**
	 * Получить все записи каталога (сканирует лениво, если кэш пуст).
	 */
	public function getAll(): array {
		if (self::$cache === null) {
			$this -> scan();
		}
		return self::$cache ?? [];
	}

	/**
	 * Получить одну запись каталога по ID манифеста.
	 */
	public function getById(string $id): ?array {
		$all = $this -> getAll();
		return $all[$id] ?? null;
	}

	/**
	 * Распаковать .bin из ZIP во временный файл и вернуть путь к нему.
	 * Вызывающий обязан удалить файл и временную директорию после использования.
	 */
	public function getBinPath(string $id): ?string {
		$entry = $this -> getById($id);
		if (!$entry) {
			return null;
		}

		$zipPath     = $entry['zip_path'];
		$binFilename = $entry['manifest']['bin_filename'] ?? 'firmware.bin';
		$tempDir     = $this -> tempBaseDir . '/' . $id . '_' . uniqid();

		@mkdir($tempDir, 0777, true);

		// Попытка 1: ZipArchive
		$zip = new \ZipArchive();
		if ($zip -> open($zipPath) === true) {
			$zip -> extractTo($tempDir, [$binFilename]);
			$zip -> close();
			$path = $tempDir . '/' . $binFilename;
			if (file_exists($path)) {
				return $path;
			}
		}

		// Попытка 2: unzip CLI
		exec('unzip -o ' . escapeshellarg($zipPath) . ' ' . escapeshellarg($binFilename) . ' -d ' . escapeshellarg($tempDir) . ' 2>/dev/null', $out, $rc);
		$path = $tempDir . '/' . $binFilename;
		if ($rc === 0 && file_exists($path)) {
			return $path;
		}

		$this -> recursiveDelete($tempDir);
		return null;
	}

	/**
	 * Найти прошивки, совместимые с данным устройством и новее текущей версии.
	 *
	 * @param array $deviceAbout Ответ устройства на GET /about
	 */
	public function findCompatible(array $deviceAbout): array {
		$all = $this -> getAll();

		$deviceType = $deviceAbout['device_type'] ?? '';
		$platform   = $deviceAbout['platform'] ?? null;
		$channels   = isset($deviceAbout['channels']) ? (int)$deviceAbout['channels'] : null;
		$currentVersion = $deviceAbout['firmware_version'] ?? '0.0.0';

		$compatible = [];
		foreach ($all as $id => $entry) {
			$m = $entry['manifest'];

			if (($m['device_type'] ?? '') !== $deviceType) {
				continue;
			}
			if (isset($m['platform']) && $m['platform'] !== $platform) {
				continue;
			}
			if (isset($m['channels']) && (int)$m['channels'] !== $channels) {
				continue;
			}
			if ($this -> versionCompare($m['version'] ?? '0.0.0', $currentVersion) <= 0) {
				continue;
			}

			$compatible[] = [
				'id'          => $id,
				'version'     => $m['version'],
				'description' => $m['description'] ?? '',
				'changelog'   => $m['changelog'] ?? '',
			];
		}

		return $compatible;
	}

	/**
	 * Сбросить in-memory кэш.
	 */
	public function clearCache(): void {
		self::$cache = null;
	}

	// ------------------------------------------------------------------------
	// Внутренние helpers
	// ------------------------------------------------------------------------

	private function extractManifest(string $zipPath): ?array {
		$tempDir = $this -> tempBaseDir . '/' . basename($zipPath, '.zip') . '_' . uniqid();
		@mkdir($tempDir, 0777, true);

		$manifest = null;

		// Попытка 1: ZipArchive
		$zip = new \ZipArchive();
		if ($zip -> open($zipPath) === true) {
			$content = $zip -> getFromName('manifest.json');
			$zip -> close();
			if ($content !== false) {
				$manifest = $this -> parseManifest($content);
			}
		}

		// Попытка 2: unzip CLI
		if ($manifest === null) {
			exec('unzip -o ' . escapeshellarg($zipPath) . ' manifest.json -d ' . escapeshellarg($tempDir) . ' 2>/dev/null', $out, $rc);
			if ($rc === 0) {
				$manifestPath = $tempDir . '/manifest.json';
				if (file_exists($manifestPath)) {
					$content = file_get_contents($manifestPath);
					$manifest = $this -> parseManifest($content);
				}
			}
		}

		$this -> recursiveDelete($tempDir);
		return $manifest;
	}

	private function parseManifest(?string $content): ?array {
		if ($content === null || $content === '') {
			return null;
		}
		$manifest = json_decode($content, true);
		if (!is_array($manifest)) {
			return null;
		}
		if (empty($manifest['id']) || empty($manifest['device_type']) || empty($manifest['version']) || empty($manifest['bin_filename'])) {
			return null;
		}
		return $manifest;
	}

	private function versionCompare(string $a, string $b): int {
		$va = array_map('intval', explode('.', $a));
		$vb = array_map('intval', explode('.', $b));
		for ($i = 0; $i < 3; $i++) {
			$ai = $va[$i] ?? 0;
			$bi = $vb[$i] ?? 0;
			if ($ai !== $bi) {
				return $ai <=> $bi;
			}
		}
		return 0;
	}

	private function recursiveDelete(string $dir): void {
		if (!is_dir($dir)) {
			return;
		}
		$files = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
			\RecursiveIteratorIterator::CHILD_FIRST
		);
		foreach ($files as $fileInfo) {
			$action = $fileInfo -> isDir() ? 'rmdir' : 'unlink';
			$action($fileInfo -> getRealPath());
		}
		rmdir($dir);
	}
}
