<?php
/**
 * @version     admin/classes/coreupdater.php 2024-03-27 zanardigit
 * @package     Watchful Client
 * @author      Watchful
 * @authorUrl   https://watchful.net
 * @copyright   Copyright (c) 2012-2025 Watchful
 * @license     GNU/GPL v3 or later
 * @description adapted from com_joomlaupdate controller
 */

use Joomla\CMS\Installer\InstallerHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Version;
use Joomla\Component\Joomlaupdate\Administrator\Model\UpdateModel;

defined('_JEXEC') or die;

class WatchfulliCoreUpdater
{
    /** @var stdClass $extParams */
    private $extParams;
	private $options;

	/** @var JoomlaupdateModelDefault|UpdateModel */
	private $joomlaUpdateModel;
	private $helper;

	/** @var Joomla\CMS\Application\CMSWebApplicationInterface|Joomla\CMS\Application\CMSApplication */
	private $application;

	/** @var string */
	private $tmpPath;

	/**
	 * Performs the download of the update package
	 * @throws Exception
	 */
	public function __construct(stdClass $extParams)
	{
		$this->options['format']    = '{DATE}\t{TIME}\t{LEVEL}\t{CODE}\t{MESSAGE}';
		$this->options['text_file'] = 'watchfulli';
		$this->helper               = new WatchfulliHelper();
		$this->application          = WatchfulliFactory::getApplication();
		$this->tmpPath              = $this->application->get('tmp_path');
        $this->extParams            = $extParams;

		Log::addLogger($this->options, Log::INFO, ['Update', 'databasequery', 'jerror']);
	}

	/**
	 * Downloads the update package to the site.
	 *
	 * @param string $packageURL
	 */
	public function download($packageURL)
	{
        watchfulli::debug("downloading $packageURL");

		$basename = basename($packageURL);
		$target   = $this->tmpPath . '/' . $basename;

		if (!WatchfulliFileHelper::exists($target)) {
			$this->downloadPackage($packageURL, $target);
		}

		// Is it a 0-byte file? If so, re-download please.
		$filesize = @filesize($target);
		if (empty($filesize)) {
			$file = $this->downloadPackage($packageURL, $target);
		} else {
			$file = $basename;
		}

		if (!$file) {
			$this->helper->response(
				[
					'task'    => 'download',
					'status'  => 'error',
					'message' => Text::_('COM_JOOMLAUPDATE_VIEW_UPDATE_DOWNLOADFAILED'),
				]
			);
		}

        watchfulli::debug("downloaded $file");

        try {
            $this->validateHash($target);
        } catch (Exception $e) {
            $this->helper->response(
                [
                    'task'    => 'download',
                    'status'  => 'error',
                    'message' => $e->getMessage(),
                ]
            );
        }

        watchfulli::debug("validated $file");

		$this->helper->response(
			[
				'task'    => 'download',
				'status'  => 'success',
				'message' => $file,
			]
		);

	}

    /**
     * @throws Exception
     */
    private function validateHash(string $packagePath)
    {
        if (!$this->application->input->get('validatehash', 0)) {
            return;
        }
        $hashes = [ 'sha256', 'sha384', 'sha512' ];

        foreach ($hashes as $hash) {
            if (empty($this->extParams->$hash)) {
                continue;
            }

            $hashValue = hash_file($hash, $packagePath);

            if ($hashValue !== $this->extParams->$hash) {
                throw new Exception('COM_JMONITORING_UPDATE_CORE_ERROR_CHECKSUM_FAILED');
            }
        }
    }

	/**
	 * Start the installation of the new Joomla! version
	 *
	 * @param   string  $file
	 *
	 * @throws Exception
	 * @throws Exception
	 */
	public function install($file)
	{
		Log::add(Text::_('COM_JOOMLAUPDATE_UPDATE_LOG_INSTALL'), Log::INFO, 'Update');
        watchfulli::debug("installing $file");

		$this->setupCoreUpdateModel();

		/** GH #1133 */
		$useNewExtractor = version_compare(WatchfulliJoomlaVersion::getVersion(), '4.0.4', '>=');
		$settingsMethod = $useNewExtractor ? 'createUpdateFile' : 'createRestorationFile';
		$settingsFile = $useNewExtractor ? 'update.php' : 'restoration.php';
		$extractor = $useNewExtractor ? 'extract.php' : 'restore.php';

        watchfulli::debug("using $settingsMethod, $settingsFile, $extractor");

		$this->joomlaUpdateModel->$settingsMethod($file);

        watchfulli::debug("Done $settingsMethod");
		// cannot continue unless we copy update settings file to com_joomlaupdate
		// but we don't want to bother encrypting the JSON
		$src = JPATH_COMPONENT_ADMINISTRATOR . '/' . $settingsFile ;
		$dst = JPATH_ADMINISTRATOR . '/components/com_joomlaupdate/' . $settingsFile;
		$res = preg_replace("/'kickstart\.security\.password' => '.*?'/", "'kickstart.security.password' => null", file_get_contents($src));

        watchfulli::debug("Copying $src to $dst");

        WatchfulliFileHelper::write($dst, $res);

        watchfulli::debug("Copied $src to $dst");

		// extractor expects to be running in its own directory
		$cwd = getcwd();
		chdir(dirname($dst));
		// build JSON data
		$json = json_encode(['task' => 'ping']);
		WatchfulliFactory::getInput()->set('json', $json);
		// capture the output from extractor
		ob_start();
		require_once JPATH_ADMINISTRATOR . '/components/com_joomlaupdate/' . $extractor;
		$output = ob_get_clean();
		// go back to our own directory
		chdir($cwd);
		$this->helper->response(
			[
				'task'    => 'install',
				'status'  => 'success',
				'message' => 'install ok',
				'output'  => $this->parseRestoreResponse($output),
			]
		);
	}

	/**
	 * Extract the update ZIP
	 *
	 * For issue #1002, testing using JInstallerHelper::unpack() in one go, instead of the Akeeba
	 * stepped extraction, due to issue with executing mismatched code during stepping.
	 *
	 * @param   string  $file
	 *
	 * @return  void
	 */
	public function step($file)
	{
		$target = $this->tmpPath . '/' . $file;

        watchfulli::debug("step $target");

		try {
			$extractionPath = $this->unpackFile($target);
            watchfulli::debug("step $extractionPath");
		} catch (Exception $ex) {
			$this->helper->response(
				[
					'task'    => 'step',
					'status'  => 'error',
					'message' => $ex->getMessage(),
				]
			);
		}

        WatchfulliFileHelper::delete($target);

		$this->helper->response(
			[
				'task'    => 'step',
				'status'  => !empty($extractionPath) ? 'success' : 'error',
				'message' => !empty($extractionPath) ? basename($extractionPath) : 'error',
				'output'  => [
					'status'   => true,
					'message'  => null,
					'files'    => 0,
					'bytesIn'  => 0,
					'bytesOut' => 0,
					'done'     => true,
				],
			]
		);
	}

	/**
	 * Unpack a given file
	 *
	 * @param   string  $file  the name of the file to unpack
	 *
	 * @throws  Exception
	 * @return string
	 */
	private function unpackFile($file)
	{
        watchfulli::debug("unpackFile $file");
		if (!$file) {
			throw new Exception('COM_JMONITORING_CANT_UNPACK_UPDATE_EMPTY_FILE');
		}

		if (!file_exists($file)) {
			throw new Exception('COM_JMONITORING_CANT_UNPACK_UPDATE_MISSING_FILE');
		}

		if (extension_loaded('zip')) {
			return $this->quickUnpack($file);
		}

        watchfulli::debug("unpackFile using JInstallerHelper::unpack()");
		$package = InstallerHelper::unpack($file, true);
        watchfulli::debug("unpackFile done");

		if (empty($package)) {
			throw new Exception('COM_JMONITORING_CANT_UNPACK_UPDATE');
		}

		return $package['dir'];
	}

	/**
	 * @throws Exception
	 */
	private function quickUnpack($file): string
    {
        watchfulli::debug("quickUnpack $file");
		$zip = new ZipArchive();

		$extractionPath = $this->tmpPath . '/' . uniqid('install_');

		if (
			!$zip->open($file) || !$zip->extractTo($extractionPath)
		) {
			throw new Exception('COM_JMONITORING_CANT_UNPACK_UPDATE');
		}

		$zip->close();
        watchfulli::debug("quickUnpack done");

		return $extractionPath;
	}

    /**
     * Finalise the upgrade by running the necessary scripts
     *
     * @param string $directory
     *
     * @return  void
     * @throws Exception
     */
	public function finalise($directory)
	{
        watchfulli::debug("finalise $directory");
        // https://github.com/joomla/joomla-cms/issues/38474
        if (file_exists(JPATH_ADMINISTRATOR . '/cache/autoload_psr4.php')) {
            @unlink(JPATH_ADMINISTRATOR . '/cache/autoload_psr4.php');
        }

        $tmpDir = $this->tmpPath . '/' . $directory;

        $this->setupCoreUpdateModel();

        WatchfulliFileHelper::copyFolder($tmpDir, JPATH_ROOT);
        watchfulli::debug("finalise copyFolder $tmpDir to JPATH_ROOT");

		if ($this->joomlaUpdateModel->finaliseUpgrade())
		{
			$this->helper->response(
				[
					'task'    => 'finalise',
					'status'  => 'success',
					'message' => 'install ok',
				]
			);
		}

		$this->helper->response(
			[
				'task'    => 'finalise',
				'status'  => 'error',
				'message' => Text::_('COM_JOOMLAUPDATE_VIEW_UPDATE_DOWNLOADFAILED'),
			]
		);
	}

    /**
     * Removes the extracted package file.
     *
     * @param string $directory
     *
     * @return  void
     * @throws Exception
     */
	public function cleanup($directory)
	{
        watchfulli::debug("cleanup $directory");
		$directory = $this->tmpPath . '/' . $directory;

		// Remove joomla.xml from the site's root.
		$target = JPATH_ROOT . '/joomla.xml';

        WatchfulliFileHelper::delete($target);

		InstallerHelper::cleanupInstall('', $directory);

        watchfulli::debug("cleanup delete $directory");

		// Remove the restoration.php file.
		$target = JPATH_ADMINISTRATOR . '/components/com_joomlaupdate/restoration.php';

        WatchfulliFileHelper::delete($target);

		$this->helper->response(
			[
				'task'    => 'cleanup',
				'status'  => 'success',
				'message' => '',
			]
		);
	}

	/**
	 * Purges updates.
	 *
	 * @return  void
	 * @throws Exception
	 * @throws Exception
	 */
	public function purge()
	{
        watchfulli::debug("purge");
		JLoader::import('models.default', JPATH_ADMINISTRATOR . '/components/com_joomlaupdate');
		$this->setupCoreUpdateModel();

		// Purge updates
		// Check for request forgeries
		$this->joomlaUpdateModel->purge();
	}

	protected function parseRestoreResponse($response)
	{
		$delim = '###';
		if (false === strpos($response, $delim))
		{
			return $response;
		}
		list($junk, $str) = explode('###', $response, 2);
		list($junk, $str) = explode('###', strrev($str), 2);
		unset($junk);

		return json_decode(strrev($str));
	}

	/**
	 * Downloads a package file to a specific directory
	 *
	 * @param   string  $url     The URL to download from
	 * @param   string  $target  The directory to store the file
	 *
	 * @return  string|bool True on success
	 */
	protected function downloadPackage($url, $target)
	{
        watchfulli::debug("downloadPackage $url $target");
		Log::add(Text::sprintf('COM_JOOMLAUPDATE_UPDATE_LOG_URL', $url), Log::INFO, 'Update');

		// Get the handler to download the package
        try {
            $http = WatchfulliFactory::getHttp();
        } catch (Exception $e) {
            return false;
        }

        watchfulli::debug("downloadPackage delete $target");
        WatchfulliFileHelper::delete($target);


		// Download the package
		$result = $http->get($url);

        $statusCode = $result->code ?? (method_exists($result, 'getStatusCode') ? $result->getStatusCode() : null);

        watchfulli::debug("downloadPackage $url $target $statusCode");

		if (!$result || ($statusCode != 200 && $statusCode != 310)) {
			return false;
		}

        $body = $result->body ?? (method_exists($result, 'getBody') ? $result->getBody() : null);

		// Write the file to disk
        WatchfulliFileHelper::write($target, $body);

        watchfulli::debug("downloadPackage done");

		return basename($target);
	}

	/**
	 * @throws Exception
	 */
	private function setupCoreUpdateModel()
	{
        $this->joomlaUpdateModel = WatchfulliFactory::getUpdateModel();
	}
}
