<?php
declare(strict_types=1);

require_once __DIR__ . '/install_link_lib.php';

function ua_update_root(): string
{
    $programData = trim((string)(getenv('PROGRAMDATA') ?: 'C:\\ProgramData'));
    return rtrim($programData, '\\/') . DIRECTORY_SEPARATOR . 'NetworkCustomers' . DIRECTORY_SEPARATOR . 'UpdateReleases';
}

function ua_update_packages_dir(): string
{
    return ua_update_root() . DIRECTORY_SEPARATOR . 'packages';
}

function ua_update_inbox_dir(): string
{
    return ua_update_root() . DIRECTORY_SEPARATOR . 'inbox';
}

function ua_update_ensure_storage(): void
{
    foreach ([ua_update_root(), ua_update_packages_dir(), ua_update_inbox_dir()] as $dir) {
        if (!is_dir($dir)) @mkdir($dir, 0770, true);
    }
}

function ua_update_request_is_https(): bool
{
    return (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off')
        || strtolower(trim((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))) === 'https';
}

function ua_update_ensure_schema(mysqli $conn): void
{
    ua_link_ensure_schema($conn);
    @$conn->query("CREATE TABLE IF NOT EXISTS ua_update_releases (
      id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
      release_key VARCHAR(120) NOT NULL,
      version VARCHAR(40) NOT NULL,
      min_version VARCHAR(40) NOT NULL DEFAULT '',
      roles_json TEXT NOT NULL,
      manifest_json LONGTEXT NOT NULL,
      package_path VARCHAR(500) NOT NULL,
      package_sha256 CHAR(64) NOT NULL,
      package_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
      restart_required TINYINT(1) NOT NULL DEFAULT 1,
      status ENUM('active','disabled') NOT NULL DEFAULT 'active',
      created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
      updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
      PRIMARY KEY (id),
      UNIQUE KEY uq_update_release_key (release_key),
      KEY idx_update_version_status (version,status)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    @$conn->query("CREATE TABLE IF NOT EXISTS ua_update_deployments (
      id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
      release_id BIGINT UNSIGNED NOT NULL,
      request_id BIGINT UNSIGNED NOT NULL,
      status ENUM('offered','deferred','downloading','installed','failed') NOT NULL DEFAULT 'offered',
      current_version VARCHAR(40) NOT NULL DEFAULT '',
      target_version VARCHAR(40) NOT NULL DEFAULT '',
      error_message TEXT NULL,
      defer_until DATETIME NULL,
      offered_at DATETIME NULL,
      downloaded_at DATETIME NULL,
      installed_at DATETIME NULL,
      updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
      PRIMARY KEY (id),
      UNIQUE KEY uq_update_deployment (release_id,request_id),
      KEY idx_update_request_status (request_id,status)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    ua_update_ensure_storage();
}

function ua_update_safe_relative_path(string $path): string
{
    $path = str_replace('\\', '/', trim($path));
    $path = preg_replace('#/+#', '/', $path) ?? '';
    if ($path === '' || $path[0] === '/' || preg_match('/^[A-Za-z]:/', $path)) return '';
    $parts = explode('/', $path);
    foreach ($parts as $part) {
        if ($part === '' || $part === '.' || $part === '..' || strpos($part, "\0") !== false) return '';
    }
    $lower = strtolower($path);
    $protected = ['install_config.php', 'admin/install_config.php', 'admin/config.php', 'admin/db_config.php'];
    if (in_array($lower, $protected, true) || str_starts_with($lower, 'data/') || str_starts_with($lower, 'runtime/')) return '';
    return $path;
}

function ua_update_validate_manifest(array $manifest, ZipArchive $zip): array
{
    $version = trim((string)($manifest['version'] ?? ''));
    if ($version === '' || !preg_match('/^[0-9]+(?:\.[0-9]+){1,3}(?:[-+][A-Za-z0-9._-]+)?$/', $version)) {
        throw new RuntimeException('Invalid update version.');
    }
    $releaseKey = trim((string)($manifest['release_id'] ?? ''));
    if ($releaseKey === '' || !preg_match('/^[A-Za-z0-9._-]{3,120}$/', $releaseKey)) {
        throw new RuntimeException('Invalid release identifier.');
    }
    $roles = array_values(array_intersect(['branch', 'agent'], array_map('strtolower', (array)($manifest['roles'] ?? []))));
    if (!$roles) throw new RuntimeException('Update package has no supported client roles.');
    $files = [];
    $seen = [];
    foreach ((array)($manifest['files'] ?? []) as $item) {
        if (!is_array($item)) throw new RuntimeException('Invalid file entry.');
        $path = ua_update_safe_relative_path((string)($item['path'] ?? ''));
        $source = ua_update_safe_relative_path((string)($item['source'] ?? ''));
        $sha = strtolower(trim((string)($item['sha256'] ?? '')));
        $size = (int)($item['size'] ?? -1);
        if ($path === '' || $source === '' || !str_starts_with($source, 'payload/') || !preg_match('/^[a-f0-9]{64}$/', $sha) || $size < 0) {
            throw new RuntimeException('Unsafe or incomplete update file entry.');
        }
        if (isset($seen[strtolower($path)])) throw new RuntimeException('Duplicate update target.');
        $seen[strtolower($path)] = true;
        $stream = $zip->getStream($source);
        if (!$stream) throw new RuntimeException('Package payload is missing: ' . $source);
        $ctx = hash_init('sha256');
        $actualSize = 0;
        while (!feof($stream)) {
            $chunk = fread($stream, 1024 * 1024);
            if ($chunk === false) { fclose($stream); throw new RuntimeException('Unable to read package payload.'); }
            $actualSize += strlen($chunk);
            hash_update($ctx, $chunk);
        }
        fclose($stream);
        if ($actualSize !== $size || !hash_equals($sha, hash_final($ctx))) throw new RuntimeException('Payload checksum mismatch: ' . $path);
        $files[] = ['path' => $path, 'source' => $source, 'size' => $size, 'sha256' => $sha];
    }
    if (!$files) throw new RuntimeException('Update package contains no files.');
    $deletes = [];
    foreach ((array)($manifest['delete_files'] ?? []) as $path) {
        $safe = ua_update_safe_relative_path((string)$path);
        if ($safe === '') throw new RuntimeException('Unsafe delete target.');
        $deletes[] = $safe;
    }
    $manifest['schema'] = 1;
    $manifest['release_id'] = $releaseKey;
    $manifest['version'] = $version;
    $manifest['min_version'] = trim((string)($manifest['min_version'] ?? ''));
    $manifest['roles'] = $roles;
    $manifest['restart_required'] = !empty($manifest['restart_required']);
    $manifest['files'] = $files;
    $manifest['delete_files'] = array_values(array_unique($deletes));
    return $manifest;
}

function ua_update_import_inbox(mysqli $conn): array
{
    ua_update_ensure_schema($conn);
    $result = ['imported' => 0, 'errors' => []];
    foreach (glob(ua_update_inbox_dir() . DIRECTORY_SEPARATOR . '*.zip') ?: [] as $sourcePath) {
        try {
            $zip = new ZipArchive();
            if ($zip->open($sourcePath) !== true) throw new RuntimeException('Cannot open update archive.');
            $raw = $zip->getFromName('manifest.json');
            $decoded = is_string($raw) ? json_decode($raw, true) : null;
            if (!is_array($decoded)) { $zip->close(); throw new RuntimeException('Missing manifest.json.'); }
            $releaseKey = trim((string)($decoded['release_id'] ?? ''));
            if ($releaseKey === '' || !preg_match('/^[A-Za-z0-9._-]{3,120}$/', $releaseKey)) {
                $zip->close();
                throw new RuntimeException('Invalid release identifier.');
            }
            $sha = hash_file('sha256', $sourcePath);
            $size = filesize($sourcePath);
            if (!is_string($sha) || $sha === '' || $size === false) throw new RuntimeException('Cannot hash update archive.');
            $existingStmt = $conn->prepare('SELECT package_sha256 FROM ua_update_releases WHERE release_key=? LIMIT 1');
            $existingStmt->bind_param('s', $releaseKey);
            $existingStmt->execute();
            $existingResult = $existingStmt->get_result();
            $existingRow = $existingResult ? $existingResult->fetch_assoc() : null;
            $existingStmt->close();
            if (is_array($existingRow)) {
                if (!hash_equals((string)$existingRow['package_sha256'], $sha)) {
                    $zip->close();
                    throw new RuntimeException('Release identifiers are immutable; use a new release_id.');
                }
                $zip->close();
                continue;
            }
            $manifest = ua_update_validate_manifest($decoded, $zip);
            $zip->close();
            $storedName = preg_replace('/[^A-Za-z0-9._-]/', '_', $releaseKey) . '-' . substr($sha, 0, 12) . '.zip';
            $targetPath = ua_update_packages_dir() . DIRECTORY_SEPARATOR . $storedName;
            if (!is_file($targetPath) && !copy($sourcePath, $targetPath)) throw new RuntimeException('Cannot store update archive.');
            $rolesJson = json_encode($manifest['roles'], JSON_UNESCAPED_SLASHES);
            $manifestJson = json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            $version = (string)$manifest['version'];
            $minVersion = (string)$manifest['min_version'];
            $restart = !empty($manifest['restart_required']) ? 1 : 0;
            $stmt = $conn->prepare("INSERT INTO ua_update_releases
              (release_key,version,min_version,roles_json,manifest_json,package_path,package_sha256,package_size,restart_required,status)
              VALUES (?,?,?,?,?,?,?,?,?,'active')");
            if (!$stmt) throw new RuntimeException('Cannot prepare release registration.');
            $sizeInt = (int)$size;
            $stmt->bind_param('sssssssii', $releaseKey, $version, $minVersion, $rolesJson, $manifestJson, $targetPath, $sha, $sizeInt, $restart);
            if (!$stmt->execute()) { $error = $stmt->error; $stmt->close(); throw new RuntimeException($error); }
            $stmt->close();
            $result['imported']++;
        } catch (Throwable $e) {
            $result['errors'][] = basename($sourcePath) . ': ' . $e->getMessage();
        }
    }
    return $result;
}

function ua_update_authenticate_request(mysqli $conn, string $instanceId, string $secret): array
{
    $instanceId = preg_replace('/[^A-Za-z0-9_-]/', '', $instanceId) ?? '';
    if ($instanceId === '' || $secret === '') throw new RuntimeException('Missing client identity.', 400);
    $stmt = $conn->prepare('SELECT * FROM ua_link_requests WHERE instance_id=? LIMIT 1');
    $stmt->bind_param('s', $instanceId);
    $stmt->execute();
    $result = $stmt->get_result();
    $row = $result ? $result->fetch_assoc() : null;
    $stmt->close();
    if (!is_array($row) || !hash_equals((string)$row['request_secret_hash'], ua_link_secret_hash($secret))) {
        throw new RuntimeException('Invalid client credentials.', 403);
    }
    if ((string)($row['status'] ?? '') !== 'approved') throw new RuntimeException('Client link is not approved.', 403);
    return $row;
}

function ua_update_find_offer(mysqli $conn, array $request, string $currentVersion): ?array
{
    $role = strtolower((string)($request['install_type'] ?? ''));
    $rows = [];
    $rs = $conn->query("SELECT * FROM ua_update_releases WHERE status='active'");
    while ($rs && ($row = $rs->fetch_assoc())) $rows[] = $row;
    usort($rows, static fn(array $a, array $b): int => version_compare((string)$b['version'], (string)$a['version']));
    foreach ($rows as $row) {
        if ($currentVersion !== '' && version_compare((string)$row['version'], $currentVersion, '<=')) continue;
        $roles = json_decode((string)$row['roles_json'], true);
        if (!is_array($roles) || !in_array($role, $roles, true)) continue;
        $min = trim((string)$row['min_version']);
        if ($min !== '' && $currentVersion !== '' && version_compare($currentVersion, $min, '<')) continue;
        $deployment = null;
        $releaseId = (int)$row['id'];
        $requestId = (int)$request['id'];
        $stmt = $conn->prepare('SELECT * FROM ua_update_deployments WHERE release_id=? AND request_id=? LIMIT 1');
        $stmt->bind_param('ii', $releaseId, $requestId);
        $stmt->execute();
        $dr = $stmt->get_result();
        $deployment = $dr ? $dr->fetch_assoc() : null;
        $stmt->close();
        if (is_array($deployment) && (string)$deployment['status'] === 'installed') continue;
        if (is_array($deployment) && (string)$deployment['status'] === 'deferred' && !empty($deployment['defer_until']) && strtotime((string)$deployment['defer_until']) > time()) continue;
        $stmt = $conn->prepare("INSERT INTO ua_update_deployments
          (release_id,request_id,status,current_version,target_version,offered_at)
          VALUES (?,?,'offered',?,?,NOW())
          ON DUPLICATE KEY UPDATE current_version=VALUES(current_version), target_version=VALUES(target_version),
            status=IF(status='deferred' AND defer_until>NOW(),'deferred','offered'), offered_at=NOW()");
        $target = (string)$row['version'];
        $stmt->bind_param('iiss', $releaseId, $requestId, $currentVersion, $target);
        $stmt->execute();
        $stmt->close();
        $canonical = implode("\n", [
            (string)$row['release_key'], (string)$row['version'], (string)$row['package_sha256'],
            (string)$row['package_size'], ((int)$row['restart_required'] ? '1' : '0'), (string)$request['instance_id'],
        ]);
        $signature = hash_hmac('sha256', $canonical, (string)$request['request_secret_hash']);
        return [
            'release_id' => (string)$row['release_key'],
            'version' => (string)$row['version'],
            'size' => (int)$row['package_size'],
            'sha256' => (string)$row['package_sha256'],
            'restart_required' => (bool)$row['restart_required'],
            'signature' => $signature,
        ];
    }
    return null;
}

function ua_update_release_by_key(mysqli $conn, string $releaseKey): ?array
{
    $stmt = $conn->prepare("SELECT * FROM ua_update_releases WHERE release_key=? AND status='active' LIMIT 1");
    $stmt->bind_param('s', $releaseKey);
    $stmt->execute();
    $rs = $stmt->get_result();
    $row = $rs ? $rs->fetch_assoc() : null;
    $stmt->close();
    return is_array($row) ? $row : null;
}
