<?php
// This file is part of the Studyplan plugin for Moodle
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <https://www.gnu.org/licenses/>.
/**
 * Determine premium status
 * @package    local_treestudyplan
 * @copyright  2023 P.M. Kuipers
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace local_treestudyplan;
defined('MOODLE_INTERNAL') || die();

require_once($CFG->libdir.'/externallib.php');

/**
 * Handle badge information in the same style as the other classes
 */
class premium extends \external_api {

    /**
     * Toggle the variable below to enable support for premium stuff.
     * If set to false, all premium features will be enabled and no premium settings panel will be visible.
     * @var bool
     */
    private static $premiumsupported = true;

    /**
     * Certficate code
     * @var string
     */
    private static $premiumcrt = "-----BEGIN CERTIFICATE-----
MIIDSzCCAjMCFFlyhmKf1fN7U5lQL/dtlsyP24AQMA0GCSqGSIb3DQEBCwUAMGEx
CzAJBgNVBAYTAk5MMRYwFAYDVQQIDA1Ob29yZC1Ib2xsYW5kMRowGAYDVQQKDBFN
aXFyYSBFbmdpbmVlcmluZzEeMBwGA1UEAwwVVHJlZVN0dWR5cGxhbiBQcmVtaXVt
MCAXDTI0MDIxMDE2MDQwM1oYDzIxMjQwMTE3MTYwNDAzWjBhMQswCQYDVQQGEwJO
TDEWMBQGA1UECAwNTm9vcmQtSG9sbGFuZDEaMBgGA1UECgwRTWlxcmEgRW5naW5l
ZXJpbmcxHjAcBgNVBAMMFVRyZWVTdHVkeXBsYW4gUHJlbWl1bTCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBAOD7+Nf5UBYGmIadI+kRM7vSPGA12F6cyZuZ
O/JsdCWzZx3cCgVYt29DxHRvFVGrhGGLsoaMY9iXc9LdeO02jKqL3RoPo2kc5moT
SNarsKZcGZXgqo5NATmdMLqQpKAy41H0ybgXZDLq5XKs9YIRlkwSpzQTNeP49mOl
48giVX3icbpMw1TdQotalKXAtcs62o+guQJNANpjBRxPXssrmDoNXrJcAtUjNOjx
8M+8tCmwkKwBoK8F3wWxIo04kZ9KILtybMmn4VJJ6SwLEf4StphTIoru8zS7XUt8
3HbV3PsiyYErPlwIcobfcjwZJpub23bzetvxRvhpeIpLhrTGrPMCAwEAATANBgkq
hkiG9w0BAQsFAAOCAQEAQwkbP6m3sdQgXEK3mYYZvvs6R/FI9QPu/9ICA+dgfj4y
7wvL0toYYR5oXdhO9At3MYmS+0bFUmqoTS+cxsC4COpEKFbRBWwbJ3NXAw14Hx2U
ELLqMZGJNOwNV+3ZdhADrwA++AjUqu144ObrcNUqo4+A4h9R8qj+o0J50Gvwja9R
Uh67LsF4Ls8fUtqzpqct94bUl6MPMHlH4qpZlgndmQdgOwLWeQEmM8X3WtSJH90S
n8FqBInMBhGu1uz0Qeo09ke0RHRnghP9EXfig/veMegASZeEhFqmS2Bdiy6gqeZ5
Klc5I28bGbvxIV5pnL6ZSjHEDp2WreM8HB0XFJwU+Q==
-----END CERTIFICATE-----";

    /**
     * Cached status
     * @var object
     */
    private static $cachedpremiumstatus = null;

    /**
     * Check if premium is supported
     * @return bool
     */
    public static function supported() {
        return self::$premiumsupported;
    }

    /**
     * Decrypt encrypted data
     * @param string $encrypted Encrypted data
     */
    private static function decrypt($encrypted) {
        // Get the public key.
        $key = \openssl_get_publickey(self::$premiumcrt);
        if ($key === false) {
            throw new \ParseError("Error parsing public key data)");
        }
        // Determine the key size.
        $keysize = \openssl_pkey_get_details($key)["bits"];
        $blocksize = ($keysize / 8); // Bits / 8. Whether padded or not.

        // Decode data in.
        $b64 = \base64_decode($encrypted);
        if ($b64 === false) {
            throw new \ParseError("Error in base64 decoding");
        }

        $data = \str_split($b64, $blocksize);
        $decrypted = "";
        $i = 0;
        foreach ($data as $chunk) {
            if (\openssl_public_decrypt($chunk, $dchunk, $key, \OPENSSL_PKCS1_PADDING)) {
                $decrypted .= $dchunk;
            } else {
                throw new \ParseError("Error decrypting chunk $i ({$blocksize} bytes)");
            }
            $i++;
        }

        // Deprecated in PHP 8.0 and up, but included to be compatible with 7.4.
        if (\PHP_MAJOR_VERSION < 8) {
            \openssl_pkey_free($key);
        }

        return $decrypted;
    }

    /**
     * Trim headers from key data
     * @param string $data the key dat with headers
     * @return string Key without headers
     */
    private static function trim_headers($data) {
        // Headers are repeated in this function for easier testing and copy-pasting into other projects.
        $startheader   = "----- BEGIN ACTIVATION KEY -----";
        $endheader     = "----- END ACTIVATION KEY -----";

        $parts = preg_split("/\r?\n/", \trim($data));
        if (count($parts) > 2) {
            $start = -1;
            $end = -1;
            for ($i = 0; $i < count($parts); $i++) {
                // Make sure all unicode spaces are converted to normal spaces before comparing.
                $p = trim(preg_replace('/\s+/u', ' ', $parts[$i]));

                if ($p == $startheader) {
                    $start = $i + 1;
                }
                if ($start > 0 && $p == $endheader) {
                    $end = $i;
                }
            }

            if ($start < 0 || $end < 0 || $end - $start <= 0) {
                throw new \ParseError("Invalid activation key wrappers");
            } else {
                $keyslice = array_slice($parts, $start, $end - $start);
                return implode("\n", $keyslice);
            }
        } else {
            throw new \ParseError("Invalid activation key");
        }
    }

    /**
     * Check if premium status is enabled
     * @return bool
     */
    public static function enabled() {
        if (self::$premiumsupported) {
            $status = self::premiumstatus();
            return $status->enabled;
        } else {
            return true;
        }
    }

    /**
     * Check if the premium key includes a specific intent in addition to the treestudyplan.
     * @param string $intent The intent to search for
     * @return bool
     */
    public static function has_intent($intent) {
        $status = self::premiumstatus();
        if ($status->enabled) {
            return \in_array(\strtolower($intent), $status->intents);
        } else {
            return false;
        }
    }

    /**
     * Generate debug info.
     */
    public static function debuginfo() {
        $s = "<pre>";
        try {
            $activationkey = \get_config("local_treestudyplan", "premium_key");
            if (strlen($activationkey) > 0) {
                $keydata = self::trim_headers($activationkey);
                $json = self::decrypt($keydata);
                $s .= "Decoded key data:\n----\n";
                $s .= $json . "\n----\n";
                $decoded = \json_decode($json, false);
                $s .= "Read as object:\n----\n";
                $s .= \json_encode($decoded, \JSON_PRETTY_PRINT) ."\n----\n";
                $status = self::premiumstatus();
                $s .= "Parsed premium status block:\n----\n";
                $s .= \json_encode($status, \JSON_PRETTY_PRINT) ."\n----\n";
                $s .= "Message: " . self::statusdescription() . "\n";
            } else {
                $s .= "Premium key empty";
            }
        } catch (\Throwable $x) {
            $s .= "!!! " . get_class($x) . ": " . $x->getCode() . " | " . $x->getMessage(). "\n";
            $stack = explode("\n", $x->getTraceAsString());
            foreach ($stack as $l) {
                $s .= "    " . trim($l) ."\n";
            }
        }

        $s .= "</pre>";
        return $s;
    }

    /**
     * Determine, cache and retrieve premium status
     * @return object
     */
    protected static function premiumstatus() {
        if (!isset(self::$cachedpremiumstatus)) {

            // Initialize default object.
            $o = new \stdClass;
            $o->enabled = false;
            $o->intents = [];
            $o->name = "";
            $o->website = "";
            $o->expires = "";
            $o->expired = false;
            $o->issued = "";
            try {
                $activationkey = \get_config("local_treestudyplan", "premium_key");
                if (strlen($activationkey) > 0) {

                    $keydata = self::trim_headers($activationkey);
                    $json = self::decrypt($keydata);
                    $decoded = \json_decode($json, false);

                    if (is_object($decoded)) {

                        // Copy basic data/.
                        $keys = ["name", "website", "expires", "issued"];
                        foreach ($keys as $k) {
                            if (isset($decoded->$k)) {
                                $o->$k = $decoded->$k;
                            }
                        }

                        if (!empty($decoded->intent)) {
                            $o->intents = explode(",", $decoded->intent);
                        }

                        // Convert dates to DateTime for.
                        $now = new \DateTime();
                        $issuedate = new \DateTime($o->issued);
                        if ($o->expires == 'never') {
                            // If expiry date == never.
                            $expirydate = new \DateTime(); // Default to now and add a year.
                            $expirydate->add(new \DateInterval("P1Y"));
                        } else {
                            try {
                                $expirydate = new \DateTime($o->expires);
                            } catch (\Exception $x) {
                                $expirydate = new \DateTime(); // Default to now.
                            }
                        }

                        if (\in_array('treestudyplan', $o->intents)
                            && !empty($o->issued)
                            && self::website_match($o->website)
                            ) {
                            if ($expirydate > $now) {
                                $o->enabled = true;
                                $o->expired = false;
                            } else {
                                $o->expired = true;
                                $o->enabled = false;
                            }
                            // Format dates localized.
                            $o->issued = \userdate($issuedate->getTimestamp(), \get_string('strftimedate', 'langconfig'));
                            if ($o->expires == "never") {
                                $o->expires = \get_string("premium:never", 'local_treestudyplan');
                            } else {
                                $o->expires = \userdate($expirydate->getTimestamp(), \get_string('strftimedate', 'langconfig'));
                            }
                        }
                    }
                }
            } catch (\ParseError $x) {
                $o->status = \get_string("premium:invalidactivationcontent", "local_treestudyplan");
            }
            self::$cachedpremiumstatus = $o;

        }
        return self::$cachedpremiumstatus;
    }

    /**
     * Check if the current site matches the provided key
     * @param string $key Website pattern to match against
     */
    private static function website_match($key) {
        global $CFG;
        $site = $CFG->wwwroot;

         // Add double slashes to key and site if no scheme is set.
        // Basically: if no double slashes present before any dots, slashes or @s.
        if (!\preg_match_all('#^[^./@]*?//#', $key)) {
            $key = "//".$key;
        }
        if (!\preg_match_all('#^[^./@]*?//#', $site)) {
            $site = "//".$site;
        }
        // Use parse_url() to split path and host.
        $keyurl = (object)\parse_url($key);
        $siteurl = (object)\parse_url($site);

        // No match if host is empty on key or site.
        if (empty($keyurl->host) || empty($siteurl->host)) {
            return false;
        }

        if ($keyurl->host == "*") {
            // Value * matches all.
            return true;
        }

        // First match the host part.
        $keyparts = \array_reverse(\explode(".", $keyurl->host));
        $siteparts = \array_reverse(\explode(".", $siteurl->host));

        // Trim starting www from both parts, since site.domain and www.site.domain should be treated as the same.
        if (($x = \array_pop($keyparts)) != "www") {
            \array_push($keyparts, $x);
        }
        if (($x = \array_pop($siteparts)) != "www") {
            \array_push($siteparts, $x);
        }

        for ($i = 0; $i < count($keyparts); $i++) {
            // No match if the site does not have a part, but the key does. Unless the key part is .
            if (!isset($siteparts[$i])) {
                if ($keyparts[$i] != "*") {
                    return false;
                } else {
                    $i++; // Increment $i by one before break, to make sure the comparison following this loop holds.
                    break; // Stop comparison. Host part matches.
                }
            }

            // Now do a proper case insensitive check for matching.
            // Uses fnmatch to easily handle shell type wildcards.
            if (! \fnmatch($keyparts[$i], $siteparts[$i], \FNM_CASEFOLD)) {
                return false;
            }
        }
        // Fail if the site has a deeper subdomain than the key, unless the deepest key subdomain is .
        if ($keyparts[$i - 1] != '*' && count($siteparts) > ($i)) {
            return false;
        }

        // If we made it here then the host part matches. Now check the path.
        // If path is /*, matches all subpaths including /.
        $keypath = empty($keyurl->path) ? "/" : $keyurl->path;
        $sitepath = empty($siteurl->path) ? "/" : $siteurl->path;

        // Trim trailing / from both paths before comparison.
        if (\strlen($sitepath) > 1) {
            $sitepath = \rtrim($sitepath, "/");
        }
        if (\strlen($keypath) > 1) {
            $keypath = \rtrim($keypath, "/");
        }

        // Do a case insensitive fnmatch on the site so wildcards are matched too.
        return \fnmatch($keypath, $sitepath, \FNM_CASEFOLD);
    }

    /**
     * Parameter description for webservice function get_premiumstatus
     */
    public static function get_premiumstatus_parameters(): \external_function_parameters {
        return new \external_function_parameters([]);
    }

    /**
     * Return value description for webservice function get_premiumstatus
     */
    public static function get_premiumstatus_returns(): \external_description {
        return new \external_single_structure([
            "enabled"   => new \external_value(PARAM_BOOL, 'premium status enabled'),
            "website"   => new \external_value(PARAM_TEXT, 'premium registration website'),
            "name"      => new \external_value(PARAM_TEXT, 'premium registration name'),
            "expires"   => new \external_value(PARAM_TEXT, 'premium registration expiry date'),
            "expired"   => new \external_value(PARAM_BOOL, 'premium status expired'),
            "intents"   => new \external_multiple_structure(
                                new \external_value(PARAM_TEXT), 'additional intents included in the license'
                            ),
        ]);
    }

    /**
     * Get premium status information for webservice
     * @return object
     */
    public static function get_premiumstatus() {
        if (self::$premiumsupported) {
            $status = self::premiumstatus();
            $keys = [
                "enabled",
                "website",
                "name",
                "expires",
                "expired",
                "intents",
            ];

            $result = [];
            foreach ($keys as $param) {
                $result[$param] = $status->$param;
            }
            return $result;
        } else {
            return [
                "enabled" => true,
                "website" => '*',
                "name" => '*',
                "expires" => '',
                "expired" => false,
                "intents" => [],
            ];
        }
    }

    /**
     * Get a descriptive text of the current premium status
     * @return string HTML description of premium status.
     */
    public static function statusdescription() {
        $status = self::premiumstatus();

        $msg = new \stdClass;
        $msg->name = $status->name;
        $msg->issued = $status->issued;
        $msg->expires = $status->expires;
        if ($status->website != "*") {
            $msg->sitestatus = \get_string("premium:onsite", 'local_treestudyplan', $status);
        } else {
            $msg->sitestatus = \get_string("premium:anywhere", 'local_treestudyplan');
        }

        if ($status->enabled) {
            return \get_string("premium:active", 'local_treestudyplan', $msg);
        } else if ($status->expired) {
            return \get_string("premium:expired", 'local_treestudyplan', $msg);
        } else if (!self::website_match($status->website)) {
            return \get_string("premium:siteinvalid", 'local_treestudyplan', $status->website);
        } else {
            return \get_string("premium:notregistered", 'local_treestudyplan', $msg);
        }
    }

    /**
     * Throw an error if premium status is not enabled
     * @param string $message Message translation key to use in exception
     */
    public static function require_premium($message="premiumfeature:warning") {
        if (! self::enabled()) {
            throw new \moodle_exception($message, "local_treestudyplan");
        }
    }

}
