<?php

namespace Kirby\Cms;

use IntlDateFormatter;
use Kirby\Data\Json;
use Kirby\Exception\Exception;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\LogicException;
use Kirby\Filesystem\F;
use Kirby\Http\Remote;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\V;
use Throwable;

/**
 * @package   Kirby Cms
 * @author    Bastian Allgeier <bastian@getkirby.com>
 * @link      https://getkirby.com
 * @copyright Bastian Allgeier
 * @license   https://getkirby.com/license
 */
class License
{
	public const HISTORY = [
		'3' => '2019-02-05',
		'4' => '2023-11-28',
		'5' => '2025-06-24'
	];

	/**
	 * Backoff intervals (in minutes) that need to pass before
	 * the next hub reissue attempt for an expired license is made.
	 * @since 5.5.0
	 */
	protected const REISSUE_BACKOFF = [5, 60, 180, 720, 1440];
	protected const SALT = 'kwAHMLyLPBnHEskzH9pPbJsBxQhKXZnX';

	protected App $kirby;

	// cache
	protected LicenseStatus $status;
	protected LicenseType $type;

	public function __construct(
		protected string|null $activation = null,
		protected string|null $code = null,
		protected string|null $domain = null,
		protected string|null $email = null,
		protected string|null $order = null,
		protected string|null $date = null,
		protected string|null $signature = null,
		protected string|null $expires = null,
		protected int $failures = 0,
		protected string|null $checked = null
	) {
		if ($code !== null) {
			$this->code = trim($code);
		}

		if ($email !== null) {
			$this->email = $this->normalizeEmail($email);
		}

		if ($code === LicenseType::Free->prefix()) {
			$this->email ??= 'licensing@getkirby.com';
		}

		$this->kirby = App::instance();
	}

	/**
	 * Returns the activation date if available
	 */
	public function activation(
		string|IntlDateFormatter|null $format = null,
		string|null $handler = null
	): int|string|null {
		return $this->activation !== null ? Str::date(strtotime($this->activation), $format, $handler) : null;
	}

	/**
	 * Returns the license code if available
	 */
	public function code(bool $obfuscated = false): string|null
	{
		if ($this->isFree() === true) {
			return null;
		}

		if ($this->code !== null && $obfuscated === true) {
			return Str::substr($this->code, 0, 10) . str_repeat('X', 22);
		}

		return $this->code;
	}

	/**
	 * Content for the license file
	 */
	public function content(): array
	{
		$content = [
			'activation' => $this->activation,
			'code'       => $this->code,
			'date'       => $this->date,
			'domain'     => $this->domain,
			'email'      => $this->email,
			'order'      => $this->order,
			'expires'    => $this->expires,
			'signature'  => $this->signature,
		];

		// only persist the reissue bookkeeping while a
		// retry cycle is actually running
		if ($this->failures > 0) {
			$content['failures'] = $this->failures;
			$content['checked']  = $this->checked;
		}

		return $content;
	}

	/**
	 * Returns the purchase date if available
	 */
	public function date(
		string|IntlDateFormatter|null $format = null,
		string|null $handler = null
	): int|string|null {
		return $this->date !== null ? Str::date(strtotime($this->date), $format, $handler) : null;
	}

	/**
	 * Deletes the license file if it exists
	 * @since 5.1.0
	 */
	public function delete(): bool
	{
		return F::remove($this->root());
	}

	/**
	 * Returns the activation domain if available
	 */
	public function domain(): string|null
	{
		return $this->domain;
	}

	/**
	 * Returns the activation email if available
	 */
	public function email(): string|null
	{
		return $this->email;
	}

	/**
	 * Validates the email address of the license
	 */
	public function hasValidEmailAddress(): bool
	{
		return V::email($this->email) === true;
	}

	/**
	 * Hub address
	 */
	public static function hub(): string
	{
		return App::instance()->option('hub', 'https://hub.getkirby.com');
	}

	/**
	 * Checks for all required components of a valid license
	 */
	public function isComplete(): bool
	{
		if (
			$this->code !== null &&
			$this->date !== null &&
			$this->domain !== null &&
			$this->email !== null &&
			$this->order !== null &&
			$this->signature !== null &&
			$this->hasValidEmailAddress() === true &&
			$this->type() !== LicenseType::Invalid
		) {
			return true;
		}

		return false;
	}

	/**
	 * Whether the validity of the license file has expired
	 * @since 5.5.0
	 */
	public function isExpired(): bool
	{
		if ($this->expires === null) {
			return false;
		}

		return strtotime($this->expires) < time();
	}

	/**
	 * Whether it is a free license for development/private installation
	 * @since 5.5.0
	 */
	public function isFree(): bool
	{
		return $this->type() === LicenseType::Free;
	}

	/**
	 * Whether it is a free license and installed locally
	 * @since 5.5.0
	 */
	public function isFreeAndLocal(): bool
	{
		return
			$this->isFree() === true &&
			$this->kirby->system()->isLocal() === true;
	}

	/**
	 * The license is still valid for the currently
	 * installed version, but it passed the 3 year period.
	 */
	public function isInactive(): bool
	{
		return $this->renewal() < time();
	}

	/**
	 * Checks for licenses beyond their 3 year period
	 */
	public function isLegacy(): bool
	{
		if ($this->type() === LicenseType::Legacy) {
			return true;
		}

		// without an activation date, the license
		// renewal cannot be evaluated and the license
		// has to be marked as coverage ended
		if ($this->activation === null) {
			return true;
		}

		// get release date of current major version
		$major   = Str::before($this->kirby->version(), '.');
		$release = strtotime(static::HISTORY[$major] ?? '');

		// if there's no matching version in the history
		// rather throw an exception to avoid further issues
		// @codeCoverageIgnoreStart
		if ($release === false) {
			throw new InvalidArgumentException(
				message: 'The version for your license could not be found'
			);
		}
		// @codeCoverageIgnoreEnd

		// If the renewal date is older than the version launch
		// date, the license coverage has ended
		return $this->renewal() < $release;
	}

	/**
	 * Runs multiple checks to find out if the license is
	 * installed and verifiable
	 */
	public function isMissing(): bool
	{
		return
			$this->isComplete() === false ||
			$this->isOnCorrectDomain() === false ||
			$this->isSigned() === false;
	}

	/**
	 * Checks if the license is on the correct domain
	 */
	public function isOnCorrectDomain(): bool
	{
		if ($this->domain === null) {
			return false;
		}

		// compare domains
		if ($this->normalizeDomain($this->kirby->system()->indexUrl()) !== $this->normalizeDomain($this->domain)) {
			return false;
		}

		return true;
	}

	/**
	 * Compares the signature with all ingredients
	 */
	public function isSigned(): bool
	{
		if ($this->signature === null) {
			return false;
		}

		$data      = json_encode($this->signatureData());
		$signature = hex2bin($this->signature);

		if ($this->isFreeAndLocal() === true) {
			return hash('sha256', $data) === $signature;
		}

		// get the public key
		$pubKey = F::read($this->kirby->root('kirby') . '/kirby.pub');

		// verify the license signature
		return openssl_verify($data, $signature, $pubKey, 'RSA-SHA256') === 1;
	}

	/**
	 * Returns a reliable label for the license type
	 */
	public function label(): string
	{
		if ($this->status() === LicenseStatus::Missing) {
			return LicenseType::Invalid->label();
		}

		return $this->type()->label();
	}

	/**
	 * Prepares the email address to be make sure it
	 * does not have trailing spaces and is lowercase.
	 */
	protected function normalizeEmail(string $email): string
	{
		return Str::lower(trim($email));
	}

	/**
	 * Prepares the domain to be comparable
	 */
	protected function normalizeDomain(string $domain): string
	{
		// remove common "testing" subdomains as well as www.
		// to ensure that installations of the same site have
		// the same license URL; only for installations at /,
		// subdirectory installations are difficult to normalize
		if (Str::contains($domain, '/') === false) {
			if (Str::startsWith($domain, 'www.')) {
				return substr($domain, 4);
			}

			if (Str::startsWith($domain, 'dev.')) {
				return substr($domain, 4);
			}

			if (Str::startsWith($domain, 'test.')) {
				return substr($domain, 5);
			}

			if (Str::startsWith($domain, 'staging.')) {
				return substr($domain, 8);
			}
		}

		return $domain;
	}

	/**
	 * Returns the order id if available
	 */
	public function order(): string|null
	{
		return $this->order;
	}

	/**
	 * Support the old license file dataset
	 * from older licenses
	 */
	public static function polyfill(array $license): array
	{
		return [
			'activation' => $license['activation'] ?? null,
			'code'       => $license['code']       ?? $license['license'] ?? null,
			'date'       => $license['date']  	   ?? null,
			'domain'     => $license['domain']     ?? null,
			'email'      => $license['email']      ?? null,
			'order'      => $license['order']      ?? null,
			'expires'    => $license['expires']    ?? null,
			'signature'  => $license['signature']  ?? null,
		];
	}

	/**
	 * Reads the license file in the config folder
	 * and creates a new license instance for it.
	 */
	public static function read(): static
	{
		try {
			$license = Json::read(static::root());
		} catch (Throwable) {
			return new static();
		}

		return new static(
			...static::polyfill($license),
			failures: (int)($license['failures'] ?? 0),
			checked: $license['checked'] ?? null
		);
	}

	/**
	 * Sends a request to the hub to register the license
	 */
	public function register(bool $reissue = false): static
	{
		if ($this->type() === LicenseType::Invalid) {
			throw new InvalidArgumentException(
				key: 'license.format'
			);
		}

		if ($this->hasValidEmailAddress() === false) {
			throw new InvalidArgumentException(
				key: 'license.email'
			);
		}

		if ($this->domain === null) {
			throw new InvalidArgumentException(
				key: 'license.domain'
			);
		}

		if ($this->isFreeAndLocal() === true) {
			$response = $this->selfsign([
				'activation' => date('Y-m-d H:i:s'),
				'code'       => $this->code,
				'date'       => date('Y-m-d H:i:s'),
				'domain'     => $this->domain,
				'email'      => $this->email,
				'order'      => '12345678',
			]);
		}

		// @codeCoverageIgnoreStart
		$response ??= $this->request('register', [
			'license' => $this->code,
			'email'   => $this->email,
			'domain'  => $this->domain,
			'reissue' => $reissue
		]);

		return $this->update($response);
		// @codeCoverageIgnoreEnd
	}

	/**
	 * Tries to reissue an expired license via the license hub.
	 *
	 * Uses an exponential backoff (see `::REISSUE_BACKOFF`) so
	 * that a temporarily unreachable hub neither triggers a
	 * request on every single Panel request nor wipes a
	 * potentially re-issuable license. Once all retires have been
	 * made unsuccessfully, the expired license file is deleted.
	 *
	 * @since 5.5.0
	 */
	public function reissue(): void
	{
		// only expired licenses need to be reissued
		if ($this->isExpired() === false) {
			return;
		}

		// give up once all backoff intervals have been used up
		if ($this->failures > count(static::REISSUE_BACKOFF)) {
			$this->delete();
			return;
		}

		// after a previous failure, wait for its backoff window
		// (in minutes) to pass before contacting the hub again
		if (
			$this->failures > 0 &&
			$this->checked !== null &&
			time() - strtotime($this->checked) < static::REISSUE_BACKOFF[$this->failures - 1] * 60
		) {
			return;
		}

		try {
			$this->register(reissue: true);
		} catch (Throwable) {
			// a transient hub or network problem must not
			// immediately delete a license: record the failed
			// attempt so following requests back off
			$this->failures++;
			$this->checked = date('Y-m-d H:i:s');

			// persist the backoff state directly
			Json::write($this->root(), $this->content());
		}
	}

	/**
	 * Returns the renewal date
	 */
	public function renewal(
		string|IntlDateFormatter|null $format = null,
		string|null $handler = null
	): int|string|null {
		if ($this->activation === null) {
			return null;
		}

		$time = strtotime('+3 years', $this->activation());
		return Str::date($time, $format, $handler);
	}

	/**
	 * Sends a hub request
	 */
	public function request(string $path, array $data): array
	{
		// @codeCoverageIgnoreStart
		$response = Remote::get(static::hub() . '/' . $path, [
			'data'    => $data,
			'headers' => [
				'Kirby-Version' => $this->kirby->version()
			]
		]);

		// handle request errors
		if ($response->code() !== 200) {
			$message = $response->json()['message'] ?? 'The request failed';

			throw new LogicException(
				key: $response->code(),
				message: $message,
			);
		}

		return $response->json();
		// @codeCoverageIgnoreEnd
	}

	/**
	 * Returns the root path to the license file
	 * @since 5.1.0
	 */
	public static function root(): string
	{
		return App::instance()->root('license');
	}

	/**
	 * Saves the license in the config folder
	 */
	public function save(): bool
	{
		if ($this->status()->activatable() !== true) {
			throw new InvalidArgumentException(
				key: 'license.verification'
			);
		}

		// save the license information
		return Json::write(
			file: $this->root(),
			data: $this->content()
		);
	}

	/**
	 * Self-signs a license file where registration
	 * will not communicate with the license hub
	 */
	protected function selfsign(array $payload): array
	{
		$data          = $payload;
		$data['email'] = hash('sha256', $data['email'] . static::SALT);
		$data          = json_encode($data);

		return [
			...$payload,
			'signature' => bin2hex(hash('sha256', $data))
		];
	}

	/**
	 * Returns the signature if available
	 */
	public function signature(): string|null
	{
		return $this->signature;
	}

	/**
	 * Creates the signature data array to compare
	 * with the signature in ::isSigned
	 */
	public function signatureData(): array
	{
		if ($this->type() === LicenseType::Legacy) {
			return [
				'license'    => $this->code,
				'order'      => $this->order,
				'email'      => hash('sha256', $this->email . static::SALT),
				'domain'     => $this->domain,
				'date'       => $this->date,
			];
		}

		$data = [
			'activation' => $this->activation,
			'code'       => $this->code,
			'date'       => $this->date,
			'domain'     => $this->domain,
			'email'      => hash('sha256', $this->email . static::SALT),
			'order'      => $this->order,
		];

		if ($this->expires !== null) {
			$data['expires'] = $this->expires;
		}

		return $data;
	}

	/**
	 * Returns the license status as string
	 * This is used to build the proper UI elements
	 * for the license activation
	 */
	public function status(): LicenseStatus
	{
		return $this->status ??= match (true) {
			$this->isMissing()  => LicenseStatus::Missing,
			$this->isFree()     => LicenseStatus::Acknowledged,
			$this->isLegacy()   => LicenseStatus::Legacy,
			$this->isInactive() => LicenseStatus::Inactive,
			default             => LicenseStatus::Active
		};
	}

	/**
	 * Detects the license type if the license key is available
	 */
	public function type(): LicenseType
	{
		return $this->type ??= LicenseType::detect($this->code);
	}

	/**
	 * Updates the license file
	 */
	public function update(array $data): static
	{
		// decode the response
		$data = static::polyfill($data);

		$this->activation = $data['activation'];
		$this->code       = $data['code'];
		$this->date       = $data['date'];
		$this->order      = $data['order'];
		$this->expires    = $data['expires'];
		$this->signature  = $data['signature'];

		// a successful (re)issue clears the backoff
		$this->failures = 0;
		$this->checked  = null;

		// clear the caches
		unset($this->status, $this->type);

		// save the new state of the license
		$this->save();

		return $this;
	}

	/**
	 * Sends an upgrade request to the hub in order
	 * to either redirect to the upgrade form or
	 * sync the new license state
	 *
	 * @codeCoverageIgnore
	 */
	public function upgrade(): array
	{
		$response = $this->request('upgrade', [
			'domain'  => $this->domain,
			'email'   => $this->email,
			'license' => $this->code,
		]);

		// the license still needs an upgrade
		if (empty($response['url']) === false) {
			// validate the redirect URL
			if (Str::startsWith($response['url'], static::hub()) === false) {
				throw new Exception(
					message: 'We couldn’t redirect you to the Hub'
				);
			}

			return [
				'status' => 'upgrade',
				'url'    => $response['url']
			];
		}

		// the license has already been upgraded
		// and can now be replaced
		$this->update($response);

		return [
			'status' => 'complete',
		];
	}
}
