<?php
declare(strict_types=1);

class FreshRSS_update_Controller extends FreshRSS_ActionController {

	private const LASTUPDATEFILE = 'last_update.txt';

	public static function isGit(): bool {
		return is_dir(FRESHRSS_PATH . '/.git/');
	}

	/**
	 * Automatic change to the new name of edge branch since FreshRSS 1.18.0,
	 * and perform checks for several git errors.
	 * @throws Minz_Exception
	 */
	public static function migrateToGitEdge(): bool {
		if (!is_writable(FRESHRSS_PATH . '/.git/config')) {
			throw new Minz_Exception('Error during git checkout: .git directory does not seem writable! ' .
				'Please git pull manually!');
		}

		if (!function_exists('exec')) {
			throw new Minz_Exception('Error during git checkout: exec() function is disabled! ' .
				'Please git pull manually!');
		}

		exec('git --version', $output, $return);
		if ($return != 0) {
			throw new Minz_Exception("Error {$return} git not found: Please update manually!");
		}

		//Note `git branch --show-current` requires git 2.22+
		exec('git symbolic-ref --short HEAD 2>&1', $output, $return);
		if ($return != 0) {
			throw new Minz_Exception("Error {$return} during git symbolic-ref: " .
				'Reapply `chown www-data:www-data -R ' . FRESHRSS_PATH . '` ' .
				'or git pull manually! ' .
				json_encode($output, JSON_UNESCAPED_SLASHES));
		}
		$line = implode('', $output);
		if ($line !== 'master' && $line !== 'dev') {
			return true;	// not on master or dev, nothing to do
		}

		Minz_Log::warning('Automatic migration to git edge branch');
		unset($output);
		exec('git checkout edge --guess -f', $output, $return);
		if ($return != 0) {
			throw new Minz_Exception("Error {$return} during git checkout to edge branch! ' .
				'Please change branch manually!");
		}

		unset($output);
		exec('git reset --hard FETCH_HEAD', $output, $return);
		if ($return != 0) {
			throw new Minz_Exception("Error {$return} during git reset! Please git pull manually!");
		}

		return true;
	}

	public static function getCurrentGitBranch(): string {
		$output = [];
		exec('git branch --show-current', $output, $return);
		if ($return === 0) {
			return 'git branch: ' . $output[0];
		} else {
			return 'git';
		}
	}

	public static function hasGitUpdate(): bool {
		$cwd = getcwd();
		if ($cwd === false) {
			Minz_Log::warning('getcwd() failed');
			return false;
		}
		chdir(FRESHRSS_PATH);
		$output = [];
		try {
			/** @throws ValueError */
			exec('git fetch --prune', $output, $return);
			if ($return == 0) {
				$output = [];
				exec('git status -sb --porcelain remote', $output, $return);
			} else {
				$line = implode('; ', $output);
				Minz_Log::warning('git fetch warning: ' . $line);
			}
		} catch (Throwable $e) {
			Minz_Log::warning('git fetch error: ' . $e->getMessage());
		}
		chdir($cwd);
		$line = implode('; ', $output);
		return $line == '' ||
			str_contains($line, '[behind') || str_contains($line, '[ahead') || str_contains($line, '[gone');
	}

	/** @return string|true */
	public static function gitPull(): string|bool {
		Minz_Log::notice(_t('admin.update.viaGit'));
		$cwd = getcwd();
		if ($cwd === false) {
			Minz_Log::warning('getcwd() failed');
			return 'getcwd() failed';
		}
		chdir(FRESHRSS_PATH);
		$output = [];
		$return = 1;
		try {
			exec('git fetch --prune', $output, $return);
			if ($return == 0) {
				$output = [];
				exec('git reset --hard FETCH_HEAD', $output, $return);
			}

			$output = [];
			self::migrateToGitEdge();
		} catch (Throwable $e) {
			Minz_Log::warning('Git error: ' . $e->getMessage());
			$output = $e->getMessage();
			$return = 1;
		}
		chdir($cwd);
		$line = is_array($output) ? implode('; ', $output) : $output;
		return $return == 0 ? true : 'Git error: ' . $line;
	}

	#[\Override]
	public function firstAction(): void {
		if (!FreshRSS_Auth::hasAccess('admin')) {
			Minz_Error::error(403);
		}

		if (!(Minz_Request::actionName() === 'apply' && Minz_Request::paramBoolean('post_conf')) &&
			FreshRSS_Auth::requestReauth()) {
			return;
		}

		include_once LIB_PATH . '/lib_install.php';

		invalidateHttpCache();

		$this->view->is_release_channel_stable = $this->is_release_channel_stable(FRESHRSS_VERSION);

		$this->view->update_to_apply = false;
		$this->view->last_update_time = 'unknown';
		$timestamp = @filemtime(join_path(DATA_PATH, self::LASTUPDATEFILE));
		if ($timestamp !== false) {
			$this->view->last_update_time = timestamptodate($timestamp);
		}
	}

	public function indexAction(): void {
		FreshRSS_View::prependTitle(_t('admin.update.title') . ' · ');

		if (file_exists(UPDATE_FILENAME)) {
			// There is an update file to apply!
			$version = @file_get_contents(join_path(DATA_PATH, self::LASTUPDATEFILE));
			if ($version == '') {
				$version = 'unknown';
			}
			if (@touch(FRESHRSS_PATH . '/index.html')) {
				$this->view->update_to_apply = true;
				$this->view->message = [
					'status' => 'good',
					'title' => _t('gen.short.ok'),
					'body' => _t('feedback.update.can_apply', $version),
				];
			} else {
				$this->view->message = [
					'status' => 'bad',
					'title' => _t('gen.short.damn'),
					'body' => _t('feedback.update.file_is_nok', $version, FRESHRSS_PATH),
				];
			}
		}
	}

	private function is_release_channel_stable(string $currentVersion): bool {
		return !str_contains($currentVersion, 'dev') && !str_contains($currentVersion, 'edge');
	}

	/*  Check installation if there is a newer version.
		via Git, if available.
		Else via system configuration  auto_update_url
	*/
	public function checkAction(): void {
		FreshRSS_View::prependTitle(_t('admin.update.title') . ' · ');
		$this->view->_path('update/index.phtml');

		if (file_exists(UPDATE_FILENAME)) {
			// There is already an update file to apply: we don’t need to check
			// the webserver!
			// Or if already check during the last hour, do nothing.
			Minz_Request::forward(['c' => 'update'], true);

			return;
		}

		$script = '';

		if (self::isGit()) {
			if (self::hasGitUpdate()) {
				$version = self::getCurrentGitBranch();
			} else {
				$this->view->message = [
					'status' => 'latest',
					'body' => _t('feedback.update.none'),
				];
				@touch(join_path(DATA_PATH, self::LASTUPDATEFILE));
				return;
			}
		} else {
			$auto_update_url = FreshRSS_Context::systemConf()->auto_update_url . '/?v=' . FRESHRSS_VERSION;
			Minz_Log::debug('HTTP GET ' . $auto_update_url);
			$curlResource = curl_init($auto_update_url);

			if ($curlResource === false) {
				Minz_Log::warning('curl_init() failed');
				$this->view->message = [
					'status' => 'bad',
					'title' => _t('gen.short.damn'),
					'body' => _t('feedback.update.server_not_found', $auto_update_url)
				];
				return;
			}
			curl_setopt($curlResource, CURLOPT_RETURNTRANSFER, true);
			curl_setopt($curlResource, CURLOPT_SSL_VERIFYPEER, true);
			curl_setopt($curlResource, CURLOPT_SSL_VERIFYHOST, 2);

			$curl_options = [];
			if (defined('CURLOPT_PROTOCOLS_STR') && is_int(CURLOPT_PROTOCOLS_STR)) {
				$curl_options[CURLOPT_PROTOCOLS_STR] = 'http,https';
				if (defined('CURLOPT_REDIR_PROTOCOLS_STR') && is_int(CURLOPT_REDIR_PROTOCOLS_STR)) {
					$curl_options[CURLOPT_REDIR_PROTOCOLS_STR] = 'http,https';
				}
			} elseif (defined('CURLPROTO_HTTP') && defined('CURLPROTO_HTTPS')) {
				// Legacy PHP 8.2-
				if (defined('CURLOPT_PROTOCOLS')) {
					$curl_options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
				}
				if (defined('CURLOPT_REDIR_PROTOCOLS')) {
					$curl_options[CURLOPT_REDIR_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
				}
			}
			curl_setopt_array($curlResource, $curl_options);

			$result = curl_exec($curlResource);
			$curlGetinfo = curl_getinfo($curlResource, CURLINFO_HTTP_CODE);
			$curlError = curl_error($curlResource);

			if ($curlGetinfo !== 200) {
				Minz_Log::warning(
					'Error during update (HTTP code ' . $curlGetinfo . '): ' . $curlError
				);

				$this->view->message = [
					'status' => 'bad',
					'body' => _t('feedback.update.server_not_found', $auto_update_url),
				];
				return;
			}

			$res_array = explode("\n", (string)$result, 2);
			$status = $res_array[0];
			if (!str_starts_with($status, 'UPDATE')) {
				$this->view->message = [
					'status' => 'latest',
					'body' => _t('feedback.update.none'),
				];
				@touch(join_path(DATA_PATH, self::LASTUPDATEFILE));
				return;
			}

			$script = $res_array[1];
			$version = explode(' ', $status, 2);
			$version = $version[1];

			Minz_Log::notice(_t('admin.update.copiedFromURL', $auto_update_url));
		}

		if (file_put_contents(UPDATE_FILENAME, $script) !== false) {
			@file_put_contents(join_path(DATA_PATH, self::LASTUPDATEFILE), $version);
			Minz_Request::forward(['c' => 'update'], true);
		} else {
			$this->view->message = [
				'status' => 'bad',
				'body' => _t('feedback.update.error', 'Cannot save the update script'),
			];
		}
	}

	public function applyAction(): void {
		if (FreshRSS_Context::systemConf()->disable_update || !file_exists(UPDATE_FILENAME) || !touch(FRESHRSS_PATH . '/index.html')) {
			Minz_Request::forward(['c' => 'update'], true);
		}

		if (Minz_Request::paramBoolean('post_conf')) {
			if (self::isGit()) {
				$res = !self::hasGitUpdate();
			} else {
				require UPDATE_FILENAME;
				// @phpstan-ignore function.notFound
				$res = do_post_update();
			}

			Minz_ExtensionManager::callHookVoid(Minz_HookType::PostUpdate);

			if ($res === true) {
				@unlink(UPDATE_FILENAME);
				@file_put_contents(join_path(DATA_PATH, self::LASTUPDATEFILE), '');
				Minz_Log::notice(_t('feedback.update.finished'));
				Minz_Request::good(
					_t('feedback.update.finished'),
					showNotification: FreshRSS_Context::userConf()->good_notification_timeout > 0
				);
			} else {
				Minz_Log::error(_t('feedback.update.error', is_string($res) ? $res : 'unknown'));
				Minz_Request::bad(_t('feedback.update.error', is_string($res) ? $res : 'unknown'), [ 'c' => 'update', 'a' => 'index' ]);
			}
		} else {
			$res = false;

			if (self::isGit()) {
				$res = self::gitPull();
			} else {
				require UPDATE_FILENAME;
				if (Minz_Request::isPost()) {
					// @phpstan-ignore function.notFound
					save_info_update();
				}
				// @phpstan-ignore function.notFound
				if (!need_info_update()) {
					// @phpstan-ignore function.notFound
					$res = apply_update();
				} else {
					return;
				}
			}

			if (function_exists('opcache_reset')) {
				opcache_reset();
			}

			if ($res === true) {
				Minz_Request::forward([
					'c' => 'update',
					'a' => 'apply',
					'params' => ['post_conf' => '1'],
					], true);
			} else {
				Minz_Log::error(_t('feedback.update.error', is_string($res) ? $res : 'unknown'));
				Minz_Request::bad(_t('feedback.update.error', is_string($res) ? $res : 'unknown'), [ 'c' => 'update', 'a' => 'index' ]);
			}
		}
	}

	/**
	 * Check PHP and its extensions are well-installed.
	 *
	 * @return array<string,'ok'|'ko'|'warn'> of tested values.
	 */
	private static function check_install_php(): array {
		require_once LIB_PATH . '/lib_install.php';
		return checkRequirements(FreshRSS_Context::systemConf()->db['type'] ?? '', checkPhp: true, checkFiles: false);
	}

	/**
	 * Check different data files and directories exist.
	 * @return array<string,'ok'|'ko'|'warn'> of tested values.
	 */
	private static function check_install_files(): array {
		require_once LIB_PATH . '/lib_install.php';
		return checkRequirements(FreshRSS_Context::systemConf()->db['type'] ?? '', checkPhp: false, checkFiles: true);
	}

	/**
	 * Check database is well-installed.
	 *
	 * @return array<string,array<string,bool>|bool> of tested values.
	 */
	private static function check_install_database(): array {
		$status = [
			'connection' => true,
			'tables' => false,
			'table' => [
				'categories' => false,
				'feeds' => false,
				'entries' => false,
				'entrytmp' => false,
				'tag' => false,
				'entrytag' => false,
			],
		];

		try {
			$dbDAO = FreshRSS_Factory::createDatabaseDAO();

			$status['tables'] = $dbDAO->tablesAreCorrect();
			$status['table']['categories'] = $dbDAO->categoryIsCorrect();
			$status['table']['feeds'] = $dbDAO->feedIsCorrect();
			$status['table']['entries'] = $dbDAO->entryIsCorrect();
			$status['table']['entrytmp'] = $dbDAO->entrytmpIsCorrect();
			$status['table']['tag'] = $dbDAO->tagIsCorrect();
			$status['table']['entrytag'] = $dbDAO->entrytagIsCorrect();
		} catch (Minz_PDOConnectionException $e) {
			$status['connection'] = false;
		}

		return $status;
	}

	/**
	 * This action displays information about installation.
	 */
	public function checkInstallAction(): void {
		FreshRSS_View::prependTitle(_t('install.check._') . ' · ');

		$this->view->status_php = self::check_install_php();
		$this->view->status_files = self::check_install_files();
		$this->view->status_database = self::check_install_database();
	}
}
