<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    health_check
 */

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__health_check()
{
    if (!defined('CHECK_CONTEXT__INSTALL')) {
        define('CHECK_CONTEXT__INSTALL', 0);
        define('CHECK_CONTEXT__TEST_SITE', 1);
        define('CHECK_CONTEXT__LIVE_SITE', 2);
        define('CHECK_CONTEXT__PROBING_FOR_SECTIONS', 3);
        define('CHECK_CONTEXT__SPECIFIC_PAGE_LINKS', 4);

        define('HEALTH_CHECK__FAIL', 'FAIL');
        define('HEALTH_CHECK__PASS', 'PASS');
        define('HEALTH_CHECK__SKIP', 'SKIP');
        define('HEALTH_CHECK__MANUAL', 'MANUAL');
    }

    require_lang('health_check');

    global $HEALTH_CHECK_LOG_FILE;
    $HEALTH_CHECK_LOG_FILE = null;

    global $HEALTH_CHECK_PAGE_RESPONSE_CACHE, $HEALTH_CHECK_COMCODE_PAGE_CONTENT_CACHE, $HEALTH_CHECK_PAGE_URLS_CACHE;
    $HEALTH_CHECK_PAGE_RESPONSE_CACHE = [];
    $HEALTH_CHECK_COMCODE_PAGE_CONTENT_CACHE = [];
    $HEALTH_CHECK_PAGE_URLS_CACHE = [];
}

/**
 * Get a nice, formatted HTML list to select Health Check sections.
 *
 * @param  array $default List of sections to select by default
 * @return Tempcode The list of sections
 */
function create_selection_list_health_check_sections(array $default) : object
{
    $categories = find_health_check_categories_and_sections();

    $list = new Tempcode();
    foreach ($categories as $category_label => $results) {
        foreach (array_keys($results) as $section_label) {
            $compound_label = $category_label . ' \\ ' . $section_label;

            $is_selected = in_array($compound_label, $default);

            $list->attach(form_input_list_entry($compound_label, $is_selected));
        }
    }
    return $list;
}

/**
 * Find all the Health Check categories and sections.
 *
 * @param  boolean $show_unusable_categories Whether to include categories that might not be accessible for some reason
 * @return array List of result categories
 */
function find_health_check_categories_and_sections(bool $show_unusable_categories = false) : array
{
    $check_context = CHECK_CONTEXT__PROBING_FOR_SECTIONS;

    $categories = [];

    $hook_obs = find_all_hook_obs('systems', 'health_checks', 'Hook_health_check_');
    foreach ($hook_obs as $ob) {
        list($category_label, $sections) = $ob->run(null, $check_context, true, false, null, null, null, $show_unusable_categories);

        cms_mb_ksort($sections, SORT_NATURAL | SORT_FLAG_CASE);

        if (!array_key_exists($category_label, $categories)) {
            $categories[$category_label] = [];
        }
        $categories[$category_label] += $sections;
    }
    cms_mb_ksort($categories, SORT_NATURAL | SORT_FLAG_CASE);

    return $categories;
}

/**
 * Script to run a Health Check.
 *
 * @ignore
 */
function health_check_script()
{
    if (!addon_installed('health_check')) {
        warn_exit(do_lang_tempcode('MISSING_ADDON', escape_html('health_check')));
    }

    header('X-Robots-Tag: noindex');

    if (!is_cli()) {
        if (!has_actual_page_access(get_member(), 'admin_health_check', 'adminzone')) {
            require_lang('permissions');
            fatal_exit(do_lang_tempcode('ACCESS_DENIED__PAGE_ACCESS', escape_html($GLOBALS['FORUM_DRIVER']->get_username(get_member()))));
        }
    }

    $_sections_to_run = get_param_string('sections_to_run', null);
    if ($_sections_to_run === null) {
        $sections_to_run = (get_option('hc_cron_sections_to_run') == '') ? [] : explode(',', get_option('hc_cron_sections_to_run'));
    } else {
        $sections_to_run = ($_sections_to_run == '') ? [] : explode(',', $_sections_to_run);
    }
    $show_passes = (get_param_integer('passes', 0) == 1);
    $show_skips = (get_param_integer('skips', 0) == 1);
    $show_manual_checks = (get_param_integer('manual_checks', 0) == 1);

    $has_fails = false;
    $categories = run_health_check($has_fails, $sections_to_run, $show_passes, $show_skips, $show_manual_checks);

    header('Content-Type: text/plain; charset=' . get_charset());
    cms_ini_set('ocproducts.xss_detect', '0');

    $out = display_health_check_results_as_text($categories);
}

/**
 * Take Health Check results and convert into a simple text output.
 *
 * @param  array $categories Results
 * @return string Output
 */
function display_health_check_results_as_text(array $categories) : string
{
    $out = '';
    foreach ($categories as $category_label => $sections) {
        foreach ($sections['SECTIONS'] as $section_label => $results) {
            foreach ($results['RESULTS'] as $result) {
                $out .= $result['RESULT'] . ': ' . strip_html($result['MESSAGE']->evaluate()) . "\n";
            }
        }
    }
    return $out;
}

/**
 * Run a Health Check.
 *
 * @param  boolean $has_fails Whether there are fails (returned by reference)
 * @param  ?array $sections_to_run Which check sections to run (null: all)
 * @param  boolean $show_passes Mention passed checks
 * @param  boolean $show_skips Mention skipped checks
 * @param  boolean $show_manual_checks Mention manual checks
 * @param  boolean $automatic_repair Do automatic repairs where possible
 * @param  ?boolean $use_test_data_for_pass Should test data be for a pass [if test data supported] (null: no test data)
 * @param  ?array $urls_or_page_links List of URLs and/or page-links to operate on, if applicable (null: those configured)
 * @param  ?array $comcode_segments Map of field names to Comcode segments to operate on, if applicable (null: N/A)
 * @param  ?integer $check_context The current state of the website (a CHECK_CONTEXT__* constant) (null: auto-decide)
 * @return array List of result categories with results, template-ready
 */
function run_health_check(bool &$has_fails, ?array $sections_to_run = null, bool $show_passes = false, bool $show_skips = false, bool $show_manual_checks = false, bool $automatic_repair = false, ?bool $use_test_data_for_pass = null, ?array $urls_or_page_links = null, ?array $comcode_segments = null, ?int $check_context = null) : array
{
    cms_extend_time_limit(TIME_LIMIT_EXTEND__CRAWL);

    if ($check_context === null) {
        if (running_script('install')) {
            $check_context = CHECK_CONTEXT__INSTALL;
        } else {
            if (health_check__is_test_site()) {
                $check_context = CHECK_CONTEXT__TEST_SITE;
            } else {
                $check_context = CHECK_CONTEXT__LIVE_SITE;
            }
        }
    }

    health_check_log_start();

    $categories = [];

    $hook_obs = find_all_hook_obs('systems', 'health_checks', 'Hook_health_check_');
    foreach ($hook_obs as $ob) {
        list($category_label, $sections) = $ob->run($sections_to_run, $check_context, $show_manual_checks, $automatic_repair, $use_test_data_for_pass, $urls_or_page_links, $comcode_segments);

        $_sections = [];
        foreach ($sections as $section_label => $results) {
            $num_fails = 0;
            $num_passes = 0;
            $num_skipped = 0;
            $num_manual = 0;
            $_results = [];
            foreach ($results as $_result) {
                $__result = $_result[0];

                if (
                    ($__result == HEALTH_CHECK__FAIL) ||
                    ($__result == HEALTH_CHECK__PASS && $show_passes) ||
                    ($__result == HEALTH_CHECK__SKIP && $show_skips) ||
                    ($__result == HEALTH_CHECK__MANUAL && $show_manual_checks) ||
                    (!in_array($__result, [HEALTH_CHECK__FAIL, HEALTH_CHECK__PASS, HEALTH_CHECK__SKIP, HEALTH_CHECK__MANUAL]))
                ) {
                    $result = [
                        'RESULT' => $__result,
                        'MESSAGE' => comcode_to_tempcode($_result[1], $GLOBALS['FORUM_DRIVER']->get_guest_id()),
                    ];
                } else {
                    $result = null; // Will not be used
                }

                switch ($__result) {
                    case HEALTH_CHECK__FAIL:
                        $has_fails = true;
                        $_results[] = $result;
                        $num_fails++;
                        break;

                    case HEALTH_CHECK__PASS:
                        if ($show_passes) {
                            $_results[] = $result;
                            $num_passes++;
                        }
                        break;

                    case HEALTH_CHECK__SKIP:
                        if ($show_skips) {
                            $_results[] = $result;
                            $num_skipped++;
                        }
                        break;

                    case HEALTH_CHECK__MANUAL:
                        if ($show_manual_checks) {
                            $_results[] = $result;
                            $num_manual++;
                        }
                        break;

                    default:
                        $_results[] = $result;
                        break;
                }
            }
            if (!empty($_results)) {
                $_sections[$section_label] = [
                    'RESULTS' => $_results,

                    'NUM_FAILS' => integer_format($num_fails),
                    'NUM_PASSES' => integer_format($num_passes),
                    'NUM_SKIPPED' => integer_format($num_skipped),
                    'NUM_MANUAL' => integer_format($num_manual),

                    '_NUM_FAILS' => strval($num_fails),
                    '_NUM_PASSES' => strval($num_passes),
                    '_NUM_SKIPPED' => strval($num_skipped),
                    '_NUM_MANUAL' => strval($num_manual),
                ];
            }
        }

        if (!empty($_sections)) {
            cms_mb_ksort($_sections, SORT_NATURAL | SORT_FLAG_CASE);
            $categories[$category_label] = [
                'SECTIONS' => $_sections,
            ];
        }
    }
    cms_mb_ksort($categories, SORT_NATURAL | SORT_FLAG_CASE);

    health_check_log_stop();

    return $categories;
}

/**
 * Start Health Check logging.
 */
function health_check_log_start()
{
    $_log_file = get_custom_file_base() . '/data_custom/health_check.log';
    $origin = running_script('index') ? get_self_url_easy() : $_SERVER['SCRIPT_NAME'];

    global $HEALTH_CHECK_LOG_FILE;
    if (is_file($_log_file)) {
        $HEALTH_CHECK_LOG_FILE = fopen($_log_file, 'at');

        fwrite($HEALTH_CHECK_LOG_FILE, loggable_date() . '  (HEALTH CHECK STARTING FROM ' . $origin . ')' . "\n");
    }

    if (is_cli()) {
        echo loggable_date() . '  (HEALTH CHECK STARTING FROM ' . $origin . ')' . "\n";
    }
}

/**
 * Stop Health Check logging.
 */
function health_check_log_stop()
{
    global $HEALTH_CHECK_LOG_FILE;
    if ($HEALTH_CHECK_LOG_FILE !== null) {
        fwrite($HEALTH_CHECK_LOG_FILE, loggable_date() . '  (HEALTH CHECK ENDING)' . "\n");

        fclose($HEALTH_CHECK_LOG_FILE);
    }

    if (is_cli()) {
        echo loggable_date() . '  (HEALTH CHECK ENDING)' . "\n";
    }
}

/**
 * Base object for Health Check hooks.
 *
 * @package health_check
 */
abstract class Hook_Health_Check
{
    protected $category_label = 'Unknown category';
    private $current_section_label = 'Unknown section';
    protected $results = [];

    /*
    HEALTH CHECK BASIC API
    */

    /**
     * Process a checks section.
     *
     * @param  string $method The method containing the checks
     * @param  string $section_label The section label
     * @param  ?array $sections_to_run Which check sections to run (null: all)
     * @param  integer $check_context The current state of the website (a CHECK_CONTEXT__* constant)
     * @param  boolean $show_manual_checks Mention manual checks
     * @param  boolean $automatic_repair Do automatic repairs where possible
     * @param  ?boolean $use_test_data_for_pass Should test data be for a pass [if test data supported] (null: no test data)
     * @param  ?array $urls_or_page_links List of URLs and/or page-links to operate on, if applicable (null: those configured)
     * @param  ?array $comcode_segments Map of field names to Comcode segments to operate on, if applicable (null: N/A)
     */
    protected function process_checks_section(string $method, string $section_label, ?array $sections_to_run, int $check_context, bool $show_manual_checks = false, bool $automatic_repair = false, ?bool $use_test_data_for_pass = null, ?array $urls_or_page_links = null, ?array $comcode_segments = null)
    {
        if (($sections_to_run !== null) && (!in_array($this->category_label . ' \\ ' . $section_label, $sections_to_run)) && (!in_array($method, $sections_to_run))) {
            return;
        }

        $this->current_section_label = $section_label;

        if ($check_context != CHECK_CONTEXT__PROBING_FOR_SECTIONS) {
            global $HEALTH_CHECK_LOG_FILE;
            if ($HEALTH_CHECK_LOG_FILE !== null) {
                fwrite($HEALTH_CHECK_LOG_FILE, loggable_date() . '  STARTING ' . $this->category_label . ' \\ ' . $section_label . "\n");
            }
            if (is_cli()) {
                echo loggable_date() . '  STARTING ' . $this->category_label . ' \\ ' . $section_label . "\n";
            }
            $time_before = microtime(true);
            call_user_func([$this, $method], $check_context, $show_manual_checks, $automatic_repair, $use_test_data_for_pass, $urls_or_page_links, $comcode_segments);
            $time_after = microtime(true);
            if ($HEALTH_CHECK_LOG_FILE !== null) {
                fwrite($HEALTH_CHECK_LOG_FILE, loggable_date() . '  FINISHED ' . $this->category_label . ' \\ ' . $section_label . ' (' . float_format($time_after - $time_before) . ' seconds)' . "\n");
            }
            if (is_cli()) {
                echo loggable_date() . '  FINISHED ' . $this->category_label . ' \\ ' . $section_label . ' (' . float_format($time_after - $time_before) . ' seconds)' . "\n";
            }
        } else {
            if (strpos($section_label, ',') !== false) {
                fatal_exit(do_lang_tempcode('INTERNAL_ERROR')); // We cannot have commas in section labels because we store label sets in comma-separated lists
            }
            if (array_key_exists($section_label, $this->results)) {
                fatal_exit('Duplicate section: ' . $section_label);
            }
            $this->results[$section_label] = null;
        }
    }

    /**
     * Add something to the health check log for the current test. Will automatically prepend the category and section.
     *
     * @param  string $contents The text to add to the log
     */
    protected function log(string $contents)
    {
        global $HEALTH_CHECK_LOG_FILE;
        if ($HEALTH_CHECK_LOG_FILE !== null) {
            fwrite($HEALTH_CHECK_LOG_FILE, loggable_date() . '  ' . $this->category_label . ' \\ ' . $this->current_section_label . ': ' . $contents . "\n");
        }
        if (is_cli()) {
            echo loggable_date() . '  ' . $this->category_label . ' \\ ' . $this->current_section_label . ': ' . $contents . "\n";
        }
    }

    /*
    CHECK REPORTING
    */

    /**
     * Report a check result, with the message if it failed.
     *
     * @param  boolean $result Whether the check passed
     * @param  string $message Failure message
     */
    protected function assertTrue(bool $result, string $message)
    {
        if (!isset($this->results[$this->current_section_label])) {
            $this->results[$this->current_section_label] = [];
        }
        if ($result) {
            $this->results[$this->current_section_label][] = [HEALTH_CHECK__PASS, $message];
        } else {
            $this->results[$this->current_section_label][] = [HEALTH_CHECK__FAIL, $message];
        }
    }

    /**
     * State a manual check.
     *
     * @param  string $message What to check
     */
    protected function stateCheckManual(string $message)
    {
        if (!isset($this->results[$this->current_section_label])) {
            $this->results[$this->current_section_label] = [];
        }
        $this->results[$this->current_section_label][] = [HEALTH_CHECK__MANUAL, $message];
    }

    /**
     * State that a check was skipped.
     * This is only called when we would like to run a check but something is stopping us; we do not call it for checks that don't make any sense to run for any reason.
     *
     * @param  string $message The reason for the skip, with possible details of exactly what was skipped
     */
    protected function stateCheckSkipped(string $message)
    {
        if (!isset($this->results[$this->current_section_label])) {
            $this->results[$this->current_section_label] = [];
        }
        $this->results[$this->current_section_label][] = [HEALTH_CHECK__SKIP, $message];
    }

    /*
    SITE URL/DOMAIN QUERYING
    */

    /**
     * Get the URL for a page-link.
     *
     * @param  string $page_link The page-link
     * @return string The URL
     */
    protected function get_page_url(string $page_link = ':') : string
    {
        global $HEALTH_CHECK_PAGE_URLS_CACHE;
        if (!array_key_exists($page_link, $HEALTH_CHECK_PAGE_URLS_CACHE)) {
            $url = page_link_to_url($page_link);
            if (strpos($url, '?') === false) {
                $url .= '?keep_su=Guest';
            } else {
                $url .= '&keep_su=Guest';
            }
            $HEALTH_CHECK_PAGE_URLS_CACHE[$page_link] = $url;
        }
        return $HEALTH_CHECK_PAGE_URLS_CACHE[$page_link];
    }

    /**
     * Convert any URLs to page-links in the given array.
     *
     * @param  ?array $_urls_or_page_links List of URLs and/or page-links (null: those configured)
     * @return array List of page-links
     */
    protected function process_urls_into_page_links(?array $_urls_or_page_links = null) : array
    {
        if ($_urls_or_page_links === null) {
            $__urls_or_page_links = trim(get_option('hc_scan_page_links'));
            if ($__urls_or_page_links == '') {
                $_urls_or_page_links = [];
            } else {
                $_urls_or_page_links = explode("\n", $__urls_or_page_links);
            }
        }

        require_code('zones3');

        $page_links = [];
        foreach ($_urls_or_page_links as $url_or_page_link) {
            if (looks_like_url($url_or_page_link)) {
                $page_links[] = url_to_page_link($url_or_page_link);
            } else {
                $page_links = array_merge($page_links, expand_wildcarded_page_links($url_or_page_link));
            }
        }

        return $page_links;
    }

    /**
     * Get a list of e-mail domains the site uses.
     *
     * @param  boolean $include_all Include all e-mail domains, as opposed to just the main outgoing one
     * @return array Map of e-mail domains to e-mail addresses on the  domain
     */
    protected function get_mail_domains(bool $include_all = true) : array
    {
        require_code('mail');

        $domains = [];
        $addresses = find_system_email_addresses($include_all);
        foreach ($addresses as $address => $domain) {
            if (!is_local_machine($domain)) {
                $domains[$domain] = $address;
            }
        }

        return $domains;
    }

    /*
    PAGE DOWNLOADING
    */

    /**
     * Download a page by page-link, but with support for directly accessing Comcode pages without needing HTTP.
     *
     * @param  string $page_link Page-link
     * @param  boolean $inner_screen_only Whether to try and restrict to just an inner Comcode screen
     * @return ?string Page content (null: no content was returned)
     */
    protected function get_page_content(string $page_link = ':', bool $inner_screen_only = false) : ?string
    {
        if ($inner_screen_only) {
            $test = $this->get_comcode_page_content($page_link);
            if ($test !== null) {
                return $test[1];
            }
        }

        $error_message = 'UNKNOWN_ERROR';
        $http_result = $this->get_page_http_content($page_link, $error_message);
        if ($http_result->data === null) {
            $this->assertTrue(false, $error_message . ' ([tt]' . $page_link . '[/tt])');
            return '';
        }
        return $http_result->data;
    }

    /**
     * Download a page by page-link.
     *
     * @param  string $page_link Page-link
     * @param  string $error_message The error message returned (passed by reference) (blank: no error)
     * @return object Response data
     */
    protected function get_page_http_content(string $page_link = ':', string &$error_message = '') : object
    {
        $error_message = 'UNKNOWN_HTTP_ERROR';
        global $HEALTH_CHECK_PAGE_RESPONSE_CACHE;
        if (!array_key_exists($page_link, $HEALTH_CHECK_PAGE_RESPONSE_CACHE)) {
            $page_link_url = $this->get_page_url($page_link);
            $ob = cms_http_request($page_link_url, ['convert_to_internal_encoding' => true, 'timeout' => 20.0, 'trigger_error' => false, 'no_redirect' => true]);
            $HEALTH_CHECK_PAGE_RESPONSE_CACHE[$page_link] = $ob;
            $error_message = 'The server cannot download from self-hosted page-link, [url="' . $page_link_url . '"][tt]' . $page_link . '[/tt][/url] (' . $ob->message . ')';
            if (get_option('site_closed') != '0') {
                $error_message .= ' (the site is currently closed)';
            }

            // Server blocked to access itself
            if ($page_link == ':') {
                $this->assertTrue($HEALTH_CHECK_PAGE_RESPONSE_CACHE[$page_link] !== null, 'The server blocked access to itself ([tt]' . $page_link . '[/tt])');
            }

            // Do not cache if we got no output; this breaks error messages
            if ($ob->data === null) {
                unset($HEALTH_CHECK_PAGE_RESPONSE_CACHE[$page_link]);
                return $ob;
            }
        }

        return $HEALTH_CHECK_PAGE_RESPONSE_CACHE[$page_link];
    }

    /**
     * Get a Comcode page-link's Comcode and HTML.
     *
     * @param  string $page_link Page-link
     * @return ?array A tuple: Comcode, HTML, Zone name, Page name (null: not a Comcode page or not a page at all)
     */
    protected function get_comcode_page_content(string $page_link) : ?array
    {
        global $HEALTH_CHECK_COMCODE_PAGE_CONTENT_CACHE;
        if (!array_key_exists($page_link, $HEALTH_CHECK_COMCODE_PAGE_CONTENT_CACHE)) {
            require_code('site');

            list($zone, $attributes) = page_link_decode($page_link);
            $page = $attributes['page'];
            $path_details = find_comcode_page(user_lang(), $page, $zone);
            if ($path_details[2] != '') {
                $comcode = cms_file_get_contents_safe($path_details[2], FILE_READ_LOCK | FILE_READ_BOM);
                $html = load_comcode_page($path_details[1], $zone, $page, $path_details[0], true);
                $ret = [$comcode, $html->evaluate(), $zone, $page];
                $HEALTH_CHECK_COMCODE_PAGE_CONTENT_CACHE[$page_link] = $ret;
                return $ret;
            }
            $HEALTH_CHECK_COMCODE_PAGE_CONTENT_CACHE[$page_link] = null;
            return null;
        }
        return $HEALTH_CHECK_COMCODE_PAGE_CONTENT_CACHE[$page_link];
    }

    /*
    PAGE SCANNING
    */

    /**
     * Get all the embedded URLs in some HTML.
     *
     * @param  string $data HTML
     * @return array List of URLs
     */
    protected function get_embed_urls_from_data(string $data) : array
    {
        $urls = [];

        require_code('xhtml');
        $data = xhtmlise_html($data, true);

        $matches = [];

        $num_matches = preg_match_all('#<link\s[^<>]*href="([^"]*)"[^<>]*rel="stylesheet"#is', $data, $matches);
        for ($i = 0; $i < $num_matches; $i++) {
            $urls[] = $matches[1][$i];
        }
        $num_matches = preg_match_all('#<link\s[^<>]*rel="stylesheet"[^<>]*href="([^"]*)"#is', $data, $matches);
        for ($i = 0; $i < $num_matches; $i++) {
            $urls[] = $matches[1][$i];
        }
        $num_matches = preg_match_all('#<script\s[^<>]*src="([^"]*)"#is', $data, $matches);
        for ($i = 0; $i < $num_matches; $i++) {
            $urls[] = $matches[1][$i];
        }
        $num_matches = preg_match_all('#<(img|audio|video|source|track|input|iframe|embed)\s[^<>]*src="([^"]*)"#is', $data, $matches);
        for ($i = 0; $i < $num_matches; $i++) {
            $urls[] = $matches[2][$i];
        }
        $num_matches = preg_match_all('#<(area)\s[^<>]*href="([^"]*)"#is', $data, $matches);
        for ($i = 0; $i < $num_matches; $i++) {
            $urls[] = $matches[2][$i];
        }
        $num_matches = preg_match_all('#<object\s[^<>]*data="([^"]*)"#is', $data, $matches);
        for ($i = 0; $i < $num_matches; $i++) {
            $urls[] = $matches[1][$i];
        }

        $urls = array_unique($urls);

        return $urls;
    }

    /**
     * Get all the hyperlinked URLs in some HTML.
     *
     * @param  string $data HTML
     * @return array List of URLs
     */
    protected function get_link_urls_from_data(string $data) : array
    {
        $urls = [];

        require_code('xhtml');
        $data = xhtmlise_html($data, true);

        $matches = [];

        $num_matches = preg_match_all('#<(a)\s[^<>]*href="([^"]*)"#is', $data, $matches);
        for ($i = 0; $i < $num_matches; $i++) {
            $urls[] = $matches[2][$i];
        }

        $urls = array_unique($urls);

        return $urls;
    }

    /*
        Homesite API
    */

    /**
     * Call a homesite API function.
     *
     * @param  string $type API type
     * @param  array $params Map of parameters
     * @return mixed API result
     */
    protected function call_homesite_api(string $type, array $params)
    {
        $url = get_brand_base_url() . '/uploads/website_specific/composr.app/scripts/api.php?type=' . urlencode($type);
        foreach ($params as $key => $_val) {
            switch (gettype($_val)) {
                case 'boolean':
                    $val = $_val ? '1' : '0';
                    break;

                case 'integer':
                    $val = strval($_val);
                    break;

                case 'double':
                    $val = float_to_raw_string($_val);
                    break;

                case 'array':
                    $val = @implode(',', array_map('strval', $_val));
                    break;

                case 'NULL':
                    $val = '';
                    break;

                case 'string':
                default:
                    $val = $_val;
                    break;
            }

            $url .= '&' . $key . '=' . urlencode($val);
        }
        return @json_decode(http_get_contents($url, ['convert_to_internal_encoding' => true, 'trigger_error' => false]), true);
    }
}

/**
 * Find whether this is a test site.
 *
 * @return boolean Whether it is
 */
function health_check__is_test_site() : bool
{
    return (get_option('hc_is_test_site') == '1') || ((get_option('hc_is_test_site') == '-1') && (get_option('site_closed') != '0'));
}
