<?php

declare(strict_types=1);

namespace PpPhp\Core\Licensing;

use RuntimeException;

/**
 * The licence server's Ed25519 signing identity.
 *
 * The SECRET key never goes in the database (a database dump is the most
 * commonly leaked artefact there is) and never goes in a release bundle —
 * storage/ is excluded by PlatformPackager, so a bundle built on the vendor's
 * machine cannot carry it by accident. It lives in one file, owner-readable.
 *
 * The PUBLIC key is the opposite: it is meant to be everywhere. Clients pin it
 * so a fake licence server cannot mint leases they will accept.
 */
final class LicenseKeys
{
    private string $path;

    public function __construct(?string $basePath = null)
    {
        $basePath ??= defined('BASE_PATH') ? constant('BASE_PATH') : dirname(__DIR__, 2);
        $dir = rtrim($basePath, '/') . '/storage/license';
        if (!is_dir($dir)) @mkdir($dir, 0700, true);
        $this->path = $dir . '/server-key.json';
    }

    public function exists(): bool
    {
        return is_file($this->path);
    }

    public function path(): string
    {
        return $this->path;
    }

    /** Create the keypair if it does not exist yet. Returns the public key (raw). */
    public function ensure(): string
    {
        if (!$this->exists()) {
            $this->generate();
        }
        return $this->publicKey();
    }

    /**
     * Mint a fresh keypair. REPLACING an existing one invalidates every lease
     * already in the field, so the caller must be explicit about it.
     */
    public function generate(bool $replaceExisting = false): array
    {
        if ($this->exists() && !$replaceExisting) {
            throw new RuntimeException('A signing key already exists. Replacing it would invalidate every licence already issued.');
        }
        $pair   = sodium_crypto_sign_keypair();
        $secret = sodium_crypto_sign_secretkey($pair);
        $public = sodium_crypto_sign_publickey($pair);

        $payload = json_encode([
            'algo'      => 'ed25519',
            'secret'    => base64_encode($secret),
            'public'    => base64_encode($public),
            'createdAt' => date('c'),
        ], JSON_PRETTY_PRINT);

        if (@file_put_contents($this->path, $payload, LOCK_EX) === false) {
            throw new RuntimeException('Could not write the signing key to ' . $this->path . ' — is storage/ writable?');
        }
        @chmod($this->path, 0600);
        sodium_memzero($secret);

        return ['public' => $public, 'publicB64' => base64_encode($public), 'path' => $this->path];
    }

    /**
     * Install a keypair received from the main server during pairing, so a
     * backup signs leases with the identical key. Overwrites any existing key
     * (a backup adopts the main's identity by definition).
     *
     * @param string $secretB64 64-byte Ed25519 secret key, base64
     * @param string $publicB64 32-byte Ed25519 public key, base64
     */
    public function importKey(string $secretB64, string $publicB64): void
    {
        $secret = base64_decode(trim($secretB64), true);
        $public = base64_decode(trim($publicB64), true);
        if (!is_string($secret) || strlen($secret) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES
            || !is_string($public) || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
            throw new RuntimeException('The signing key received from the main server was malformed.');
        }
        // Sanity: the secret must actually correspond to the public half.
        $derived = sodium_crypto_sign_publickey_from_secretkey($secret);
        if (!hash_equals($derived, $public)) {
            throw new RuntimeException('The signing key received from the main server is inconsistent.');
        }
        $dir = dirname($this->path);
        if (!is_dir($dir)) @mkdir($dir, 0700, true);
        $payload = json_encode([
            'algo'      => 'ed25519',
            'secret'    => base64_encode($secret),
            'public'    => base64_encode($public),
            'createdAt' => date('c'),
            'source'    => 'paired-from-main',
        ], JSON_PRETTY_PRINT);
        if (@file_put_contents($this->path, $payload, LOCK_EX) === false) {
            throw new RuntimeException('Could not write the signing key to ' . $this->path . ' — is storage/ writable?');
        }
        @chmod($this->path, 0600);
        sodium_memzero($secret);
    }

    /**
     * Set the current signing key aside so this install stops being a licence
     * authority — used when converting an accidentally-keyed MAIN into a BACKUP.
     *
     * The key is never destroyed: it is renamed to a timestamped
     * `server-key.archived-<ts>.json` beside it, so a mistaken conversion is
     * recoverable (rename it back). Returns the archive path, or '' if there was
     * no key to archive. After this, exists() is false and the console shows the
     * "pair as backup" wizard again.
     */
    public function archive(): string
    {
        if (!$this->exists()) {
            return '';
        }
        $dest = dirname($this->path) . '/server-key.archived-' . date('Ymd-His') . '.json';
        // Extremely unlikely collision (same second) — fall back to a uniqid suffix.
        if (is_file($dest)) {
            $dest = dirname($this->path) . '/server-key.archived-' . date('Ymd-His') . '-' . substr(bin2hex(random_bytes(3)), 0, 6) . '.json';
        }
        if (!@rename($this->path, $dest)) {
            throw new RuntimeException('Could not archive the signing key (' . $this->path . '). Check that storage/license is writable.');
        }
        @chmod($dest, 0600);
        return $dest;
    }

    private function load(): array
    {
        if (!$this->exists()) {
            throw new RuntimeException('No signing key yet — generate one before issuing licences.');
        }
        $data = json_decode((string) @file_get_contents($this->path), true);
        if (!is_array($data) || empty($data['secret']) || empty($data['public'])) {
            throw new RuntimeException('The signing key file is unreadable or corrupt: ' . $this->path);
        }
        return $data;
    }

    public function secretKey(): string
    {
        return (string) base64_decode((string) $this->load()['secret'], true);
    }

    public function publicKey(): string
    {
        return (string) base64_decode((string) $this->load()['public'], true);
    }

    public function publicKeyB64(): string
    {
        return (string) $this->load()['public'];
    }

    public function createdAt(): string
    {
        return (string) ($this->load()['createdAt'] ?? '');
    }

    /** Fingerprint of the public key, for showing in the UI / comparing installs. */
    public function keyId(): string
    {
        return strtoupper(substr(hash('sha256', $this->publicKey()), 0, 12));
    }

    /**
     * Is the file safely permissioned? Shared hosting often ignores chmod, so
     * this is reported to the owner rather than enforced.
     */
    public function permissionsOk(): bool
    {
        if (!$this->exists()) return true;
        $perms = @fileperms($this->path);
        return $perms !== false && ($perms & 0o077) === 0;
    }
}
