<?php
/**
 * @package     MefisAssetBridge
 * @subpackage  MefisAssetBridgeCore_Auth
 *
 * @copyright   Copyright (C) 2025 Goran Usljebrka. All rights reserved.
 * @license     Proprietary — No resale or incorporation into other projects without explicit written permission. see LICENSE.txt
 */
declare (strict_types = 1);

namespace MefisAssetBridgeCore\Auth;

use phpseclib3\Crypt\Common\PrivateKey;
use phpseclib3\Crypt\Common\PublicKey;
use phpseclib3\Crypt\RSA;
use phpseclib3\Exception\NoKeyLoadedException;

/**
 * Key Store class
 *
 * Manages RSA keys for signing and encryption
 */
class KeyStore
{
    /**
     * Singleton instance
     *
     * @var ?self
     */
    private static ?self $instance          = null;
    protected ?PrivateKey $privateKey       = null;
    protected ?PublicKey $publicKey         = null;
    protected ?PublicKey $remotePublicKey   = null;
    protected string $remoteServerUrl       = 'https://asset-bridge.ugoran.com/api/v1/';
    protected ?string $keyLocation          = null;
    protected const _RSA_KEY_FORMAT_PUBLIC  = 'PKCS1';
    protected const _RSA_KEY_FORMAT_PRIVATE = 'PKCS1';
    protected string $keyPrefix             = 'mefis_asset_manager_';

    /**
     * Make constructor private to enforce singleton. Use getInstance() to obtain the single instance.
     */
    private function __construct(string $publicHtmlPath, string $altDirectory = '')
    {
        $this->setKeyLocation($publicHtmlPath, $altDirectory);
    }
    private function getRemoteServerHash(): string
    {
        $url = trim($this->remoteServerUrl);
        if (strpos($url, '://') === false) {
            $url = 'http://' . $url;
        }

        $parts = parse_url($url);
        $host  = isset($parts['host']) ? strtolower($parts['host']) : '';
        $port  = isset($parts['port']) ? ':' . $parts['port'] : '';
        $path  = isset($parts['path']) ? rtrim($parts['path'], "/") : '';
        $query = isset($parts['query']) ? '?' . $parts['query'] : '';

        $normalized = $host . $port . $path . $query;
        return substr(hash('sha256', $normalized), 0, 16);
    }
    /**
     * Return the singleton instance. On first call, $publicHtmlPath is required.
     *
     * @param string|null $publicHtmlPath required on first call
     * @param string $altDirectory optional
     * @return self
     */
    public static function getInstance(?string $publicHtmlPath = null, string $altDirectory = ''): self
    {
        if (self::$instance === null) {
            if ($publicHtmlPath === null) {
                throw new \RuntimeException('KeyStore not initialized. Provide $publicHtmlPath on first call to getInstance().');
            }
            self::$instance = new self($publicHtmlPath, $altDirectory);
        }
        return self::$instance;
    }

    /**
     * Prevent cloning the singleton
     */
    private function __clone() {}

    /**
     * Prevent unserializing the singleton
     */
    public function __wakeup(): void
    {
        throw new \RuntimeException('Cannot unserialize KeyStore singleton');
    }
    public function setKeyPrefix(string $prefix): self
    {
        if (preg_match('/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/', $prefix) !== 1) {
            throw new \RuntimeException('Invalid Key Prefix.');
        }
        $this->keyPrefix = $prefix;
        return $this;
    }
    public function setRemoteServerUrl(string $url): self
    {
        $this->remoteServerUrl = trim($url);
        return $this;
    }
    public function resetKeyPrefix(): self
    {
        $this->keyPrefix = 'mefis_asset_manager';
        return $this;
    }
    /**
     * rename existing keys to new prefix
     */
    public function finalizeHandshakeRenameKeys(string $uuid)
    {
        if ($this->keyLocation === null) {
            throw new \RuntimeException('RSA key location unknown');
        }

        $privateKeyPath  = $this->keyLocation . $this->keyPrefix . '_private.key';
        $publicKeyPath   = $this->keyLocation . $this->keyPrefix . '_public.key';
        $remotePublicKey = $this->keyLocation . $this->keyPrefix . '_' . $this->getRemoteServerHash() . '.key';

        $privateKeyPathNew  = $this->keyLocation . $uuid . '_private.key';
        $publicKeyPathNew   = $this->keyLocation . $uuid . '_public.key';
        $remotePublicKeyNew = $this->keyLocation . $uuid . '_' . $this->getRemoteServerHash() . '.key';

        if (file_exists($privateKeyPath)) {
            if (! rename($privateKeyPath, $privateKeyPathNew)) {
                throw new \RuntimeException("Unable to rename private key to new prefix.");
            }
        }
        if (file_exists($publicKeyPath)) {
            if (! rename($publicKeyPath, $publicKeyPathNew)) {
                throw new \RuntimeException("Unable to rename public key to new prefix.");
            }
        }
        if (file_exists($remotePublicKey)) {
            if (! rename($remotePublicKey, $remotePublicKeyNew)) {
                throw new \RuntimeException("Unable to rename remote public key to new prefix.");
            }
        }
        return true;
    }
    public function setKeyLocation(string $publicHtmlPath, string $altDirectory = ''): self
    {

        $primary = dirname($publicHtmlPath) . DIRECTORY_SEPARATOR . '.system.keys.mf';

        // Ensure there are no trailing slashes on provided paths
        $primary = rtrim($primary, "\/\\");

        $attempt = function (string $path): ?string {
            // If exists and writable, accept
            if (is_dir($path)) {
                if (is_writable($path)) {
                    return $path;
                }
                // try to tighten permissions
                @chmod($path, 0700);
                if (is_writable($path)) {
                    return $path;
                }
                return null;
            }

            // Try to create
            if (@mkdir($path, 0700, true)) {
                @chmod($path, 0700);
                return $path;
            }

            return null;
        };

        $chosen = $attempt($primary);

        // If primary didn't work and alt provided, try alt
        if ($chosen === null && $altDirectory) {
            $alt    = rtrim($altDirectory, "\/\\");
            $chosen = $attempt($alt);
        }

        if ($chosen !== null) {

            $htaccess = "Options -Indexes\n<IfModule mod_authz_core.c>\n    Require all denied\n</IfModule>\n<IfModule mod_access.c>\n    Deny from all\n</IfModule>\n";
            $htPath   = $chosen . DIRECTORY_SEPARATOR . '.htaccess';
            if (@file_put_contents($htPath, $htaccess, LOCK_EX) === false) {
                throw new \RuntimeException('Unable to write .htaccess to key directory: ' . $htPath);
            }
            @chmod($htPath, 0640);

            $indexPath = $chosen . DIRECTORY_SEPARATOR . 'index.php';
            if (!file_exists($indexPath)) {
                @file_put_contents($indexPath, '<?php http_response_code(403); exit;' . PHP_EOL, LOCK_EX);
                @chmod($indexPath, 0640);
            }
        }

        if ($chosen === null) {
            throw new \RuntimeException('Unable to access or create key directory in primary location (' . $primary . ') or alt location (' . $altDirectory . ').');
        }

        // store absolute path
        $this->keyLocation = $chosen . DIRECTORY_SEPARATOR;

        return $this;
    }

    public function loadKeys(): void
    {
        if ($this->keyLocation === null) {
            throw new \RuntimeException('RSA key location unknown');
        }

        $privateKeyPath  = $this->keyLocation . $this->keyPrefix . '_private.key';
        $publicKeyPath   = $this->keyLocation . $this->keyPrefix . '_public.key';
        $remotePublicKey = $this->keyLocation . $this->keyPrefix . '_' . $this->getRemoteServerHash() . '.key';

        if (file_exists($privateKeyPath)) {
            $key1             = $this->loadFileContents($privateKeyPath);
            $this->privateKey = $this->loadPrivateKey($key1);
        }

        if (file_exists($publicKeyPath)) {
            $key2            = $this->loadFileContents($publicKeyPath);
            $this->publicKey = $this->loadPublicKey($key2);
        }

        if (file_exists($remotePublicKey)) {
            $key3                  = $this->loadFileContents($remotePublicKey);
            $this->remotePublicKey = $this->loadPublicKey($key3);
        }

    }
    public function getPublicKeyString(): ?string
    {
        return $this->publicKey->toString(self::_RSA_KEY_FORMAT_PUBLIC);
    }
    public function areKeysValid(): int
    {
        if (! $this->privateKey instanceof PrivateKey) {
            return 0;
        }

        if (! $this->publicKey instanceof PublicKey) {
            return 1;
        }

        if (! $this->remotePublicKey instanceof PublicKey) {
            return 2;
        }

        return 3;
    }
    /**
     * Create RSA keys and save them to files
     */
    public function createKeys(bool $replace = false)
    {

        $rsa = RSA::createKey(2048);

        $privateKey = $rsa->toString(self::_RSA_KEY_FORMAT_PRIVATE);

        $publicKey = $rsa->getPublicKey()->toString(self::_RSA_KEY_FORMAT_PUBLIC);

        $privateKeyPath = $this->keyLocation . $this->keyPrefix . '_private.key';
        $publicKeyPath  = $this->keyLocation . $this->keyPrefix . '_public.key';

        // delete existing keys if replace is true
        if ($replace) {
            $this->privateKey = null;
            $this->publicKey  = null;
            if (file_exists($privateKeyPath)) {
                @unlink($privateKeyPath);
            }

            if (file_exists($publicKeyPath)) {
                @unlink($publicKeyPath);
            }

        }

        if (file_exists($privateKeyPath) || file_exists($publicKeyPath)) {
            throw new \RuntimeException("RSA key files already exist. Aborting generation of new keys to avoid overwriting existing.");
        }
        if (file_put_contents($privateKeyPath, $privateKey) === false) {
            throw new \RuntimeException("Unable to write private key to file: " . $privateKeyPath);
        }
        if (file_put_contents($publicKeyPath, $publicKey) === false) {
            throw new \RuntimeException("Unable to write public key to file: " . $publicKeyPath);
        }
        @chmod($privateKeyPath, 0600);
        @chmod($publicKeyPath, 0600);

        $this->loadKeys();
    }
    public function deleteKeys()
    {
        if ($this->keyLocation === null) {
            throw new \RuntimeException('RSA key location unknown');
        }

        $privateKeyPath  = $this->keyLocation . $this->keyPrefix . '_private.key';
        $publicKeyPath   = $this->keyLocation . $this->keyPrefix . '_public.key';
        $remotePublicKey = $this->keyLocation . $this->keyPrefix . '_' . $this->getRemoteServerHash() . '.key';

        if (file_exists($privateKeyPath)) {
            @unlink($privateKeyPath);
        }

        if (file_exists($publicKeyPath)) {
            @unlink($publicKeyPath);
        }

        if (file_exists($remotePublicKey)) {
            @unlink($remotePublicKey);
        }

        $this->privateKey      = null;
        $this->publicKey       = null;
        $this->remotePublicKey = null;
    }

    public function saveRemotePublicKey(PublicKey $key): void
    {
        if ($this->keyLocation === null) {
            throw new \RuntimeException('RSA key location unknown');
        }

        $remotePublicKeyPath = $this->keyLocation . $this->keyPrefix . '_' . $this->getRemoteServerHash() . '.key';

        if (file_put_contents($remotePublicKeyPath, $this->getPublicKeyString($key)) === false) {
            throw new \RuntimeException("Error saving remote key.");
        }
        @chmod($remotePublicKeyPath, 0600);

        $this->remotePublicKey = $key;
    }
    /**
     * Sign string with our rsa private key
     */
    public function sign(string $string): string
    {
        $signature = $this->privateKey->sign($string);
        return base64_encode($signature);
    }
    /**
     * Verify signature with rsa public key
     * pass public key, our or server's
     * @return mixed
     */
    public function verifySignature($string, $signature, PublicKey $publicKey)
    {
        return $publicKey->verify($string, $signature);
    }

    protected function loadFileContents(string $path): string
    {
        $string = file_get_contents($path);
        if ($string === false) {
            throw new \RuntimeException("Unable to read file: " . $path);
        }
        return $string;
    }
    /**
     * Verify that we have valid RSA keys loaded
     */
    protected function loadPrivateKey(string $string): PrivateKey
    {
        try {
            return RSA::loadPrivateKey($string);
        } catch (NoKeyLoadedException $e) {
            throw new \RuntimeException("Invalid private key: " . $e->getMessage());
        }
    }
    /**
     * Verify that we have valid RSA keys loaded
     */
    public function loadPublicKey(string $string): PublicKey
    {

        try {
            return RSA::loadPublicKey($string);

        } catch (NoKeyLoadedException $e) {
            throw new \RuntimeException("Invalid public key: " . $e->getMessage());
        }
    }

    public function encrypt(string $data): string
    {
        $pub = $this->publicKey->withPadding(RSA::ENCRYPTION_OAEP)
        ->withHash('sha256')
        ->withMGFHash('sha256');
        $cipher = $pub->encrypt($data);
        return base64_encode($cipher);
    }

    public function decrypt(string $ciphertext): string
    {
        $priv = $this->privateKey->withPadding(RSA::ENCRYPTION_OAEP)
        ->withHash('sha256')
        ->withMGFHash('sha256');
        return $priv->decrypt(base64_decode($ciphertext));
    }
}
