<?php
/**
 * Converts the encrypted module certificate into a short-lived verified PEM
 * and extracts the fiscal identity carried by the X.509 subject.
 */

if (!defined('ABSPATH')) {
    exit;
}

class VerifactuCertificateManager
{
    public static function createTemporaryPem($module)
    {
        if (!is_object($module) || !method_exists($module, 'getDecryptedCertificate')) {
            throw new Exception('No se ha podido acceder al almacen seguro del certificado.');
        }
        $raw = $module->getDecryptedCertificate();
        $password = method_exists($module, 'getCertificatePassword')
            ? (string) $module->getCertificatePassword()
            : '';
        if (!is_string($raw) || $raw === '') {
            throw new Exception('El certificado configurado esta vacio.');
        }

        $pem = '';
        $certPem = '';
        $privateKeyPem = '';
        $passphrase = '';
        if (strpos($raw, '-----BEGIN CERTIFICATE-----') !== false) {
            $certPem = self::firstPemBlock($raw, 'CERTIFICATE');
            $privateKeyPem = self::findPrivateKeyBlock($raw);
            if ($certPem === '' || $privateKeyPem === '') {
                throw new Exception('El PEM debe contener el certificado y su clave privada.');
            }
            $key = @openssl_pkey_get_private($privateKeyPem, $password);
            if (!$key) {
                throw new Exception('No se ha podido abrir la clave privada del PEM con la contrasena configurada.');
            }
            $passphrase = $password;
            $pem = trim($raw) . PHP_EOL;
        } else {
            $certs = array();
            if (!@openssl_pkcs12_read($raw, $certs, $password)) {
                throw new Exception('No se ha podido abrir el P12/PFX con la contrasena configurada.');
            }
            $certPem = isset($certs['cert']) ? (string) $certs['cert'] : '';
            $privateKeyPem = isset($certs['pkey']) ? (string) $certs['pkey'] : '';
            if ($certPem === '' || $privateKeyPem === '') {
                throw new Exception('El P12/PFX no contiene certificado y clave privada.');
            }
            $pem = trim($certPem) . PHP_EOL . trim($privateKeyPem) . PHP_EOL;
            if (!empty($certs['extracerts']) && is_array($certs['extracerts'])) {
                foreach ($certs['extracerts'] as $extra) {
                    $pem .= trim((string) $extra) . PHP_EOL;
                }
            }
        }

        $certificate = @openssl_x509_read($certPem);
        $privateKey = @openssl_pkey_get_private($privateKeyPem, $password);
        if (!$certificate || !$privateKey || !@openssl_x509_check_private_key($certificate, $privateKey)) {
            throw new Exception('La clave privada no corresponde al certificado configurado.');
        }
        $info = @openssl_x509_parse($certificate);
        if (!is_array($info)) {
            throw new Exception('No se han podido leer los datos del certificado.');
        }
        $now = time();
        if (!empty($info['validFrom_time_t']) && $now < (int) $info['validFrom_time_t']) {
            throw new Exception('El certificado aun no es valido.');
        }
        if (empty($info['validTo_time_t']) || $now >= (int) $info['validTo_time_t']) {
            throw new Exception('El certificado esta caducado.');
        }

        $identity = self::extractFiscalIdentity($info);
        if (class_exists('VerifactuConfig') && method_exists('VerifactuConfig', 'persistCertificateIdentity')) {
            VerifactuConfig::persistCertificateIdentity($identity);
        }

        $path = tempnam(sys_get_temp_dir(), 'ddv_cert_');
        if ($path === false || file_put_contents($path, $pem, LOCK_EX) === false) {
            throw new Exception('No se ha podido preparar temporalmente el certificado.');
        }
        @chmod($path, 0600);

        return array(
            'path' => $path,
            'passphrase' => $passphrase,
            'info' => $info,
            'identity' => $identity,
        );
    }

    public static function cleanup(array $material)
    {
        if (!empty($material['path']) && is_file($material['path'])) {
            @unlink($material['path']);
        }
    }

    /**
     * Extracts the Spanish fiscal identity encoded in common qualified
     * certificates (FNMT and equivalent X.509 profiles).
     *
     * For a company representative certificate, organizationIdentifier/O
     * identify the represented company and serialNumber/CN identify the
     * natural-person holder. In that case representative=true.
     */
    public static function extractFiscalIdentity(array $info)
    {
        $subject = !empty($info['subject']) && is_array($info['subject']) ? $info['subject'] : array();
        $extensions = !empty($info['extensions']) && is_array($info['extensions']) ? $info['extensions'] : array();

        $orgIdentifier = self::firstSubjectValue($subject, array(
            'organizationIdentifier', 'organizationidentifier', '2.5.4.97', 'OID.2.5.4.97',
        ));
        $orgNif = self::extractSpanishNif($orgIdentifier);
        $orgName = self::firstSubjectValue($subject, array('O', 'organizationName', 'organizationname'));

        $serial = self::firstSubjectValue($subject, array('serialNumber', 'serialnumber'));
        $commonName = self::firstSubjectValue($subject, array('CN', 'commonName', 'commonname'));
        $altName = isset($extensions['subjectAltName']) ? self::scalarText($extensions['subjectAltName']) : '';

        $holderNif = self::extractSpanishNif($serial);
        if ($holderNif === '') {
            $holderNif = self::extractSpanishNif($commonName);
        }
        if ($holderNif === '') {
            $holderNif = self::extractSpanishNif($altName);
        }

        $givenName = self::firstSubjectValue($subject, array('GN', 'givenName', 'givenname'));
        $surname = self::firstSubjectValue($subject, array('SN', 'surname'));
        $holderName = trim($givenName . ' ' . $surname);
        if ($holderName === '') {
            $holderName = self::cleanCertificateName($commonName);
        }

        if ($orgNif !== '') {
            $issuerNif = $orgNif;
            $issuerName = trim($orgName) !== '' ? trim($orgName) : self::cleanCertificateName($commonName);
            $source = 'organizationIdentifier';
        } else {
            $issuerNif = $holderNif;
            $issuerName = $holderName !== '' ? $holderName : self::cleanCertificateName($commonName);
            if ($issuerName === '' && trim($orgName) !== '') {
                $issuerName = trim($orgName);
            }
            $source = 'certificateHolder';
        }

        $issuerNif = class_exists('VerifactuConfig')
            ? VerifactuConfig::normalizeNif($issuerNif)
            : preg_replace('/[^A-Z0-9]/', '', strtoupper((string) $issuerNif));
        $holderNif = class_exists('VerifactuConfig')
            ? VerifactuConfig::normalizeNif($holderNif)
            : preg_replace('/[^A-Z0-9]/', '', strtoupper((string) $holderNif));

        if (!preg_match('/^[A-Z0-9]{9}$/', $issuerNif)) {
            throw new Exception(
                'No se ha podido obtener de forma inequivoca el NIF espanol del obligado a partir del certificado. '
                . 'Use un certificado del propio emisor o un certificado de representante que identifique a la entidad representada.'
            );
        }
        $issuerName = trim((string) $issuerName);
        if ($issuerName === '') {
            throw new Exception('El certificado no contiene una razon social o nombre fiscal utilizable para el obligado a la emision.');
        }
        if (function_exists('mb_substr')) {
            $issuerName = mb_substr($issuerName, 0, 120, 'UTF-8');
            $holderName = mb_substr((string) $holderName, 0, 120, 'UTF-8');
        } else {
            $issuerName = substr($issuerName, 0, 120);
            $holderName = substr((string) $holderName, 0, 120);
        }

        $representative = $holderNif !== '' && $holderNif !== $issuerNif;
        // Qualified seal certificates identify an organisation but do not carry a
        // different natural-person holder NIF. Representative/person certificates do.
        $certificateType = ($orgNif !== '' && ($holderNif === '' || $holderNif === $issuerNif))
            ? 'sello'
            : 'persona';

        return array(
            'issuer_nif' => $issuerNif,
            'issuer_name' => $issuerName,
            'holder_nif' => $holderNif,
            'holder_name' => trim((string) $holderName),
            'representative' => $representative,
            'source' => $source,
            'certificate_type' => $certificateType,
            'certificate_serial' => isset($info['serialNumberHex']) ? (string) $info['serialNumberHex'] : '',
        );
    }

    /** Backward-compatible consistency helper. */
    public static function assertCertificateNifIfPresent(array $info, $issuerNif)
    {
        $identity = self::extractFiscalIdentity($info);
        $expected = class_exists('VerifactuConfig')
            ? VerifactuConfig::normalizeNif($issuerNif)
            : preg_replace('/[^A-Z0-9]/', '', strtoupper((string) $issuerNif));
        if ($expected !== '' && $expected !== $identity['issuer_nif']) {
            throw new Exception('El NIF indicado no coincide con el obligado identificado por el certificado.');
        }
    }

    private static function firstSubjectValue(array $subject, array $keys)
    {
        foreach ($keys as $key) {
            if (array_key_exists($key, $subject)) {
                $value = self::scalarText($subject[$key]);
                if ($value !== '') {
                    return $value;
                }
            }
        }
        return '';
    }

    private static function scalarText($value)
    {
        if (is_array($value)) {
            $parts = array();
            foreach ($value as $item) {
                if (is_scalar($item)) {
                    $parts[] = trim((string) $item);
                }
            }
            return trim(implode(' ', array_filter($parts)));
        }
        return is_scalar($value) ? trim((string) $value) : '';
    }

    private static function extractSpanishNif($text)
    {
        $text = strtoupper((string) $text);
        $patterns = array(
            '/VATES[\-:\s]*([A-Z0-9]{9})(?![A-Z0-9])/',
            '/IDCES[\-:\s]*([A-Z0-9]{9})(?![A-Z0-9])/',
            '/(?:NIF|CIF|DNI|VAT(?:NUMBER)?|OID\.2\.5\.4\.5)[\-:\s=]*(?:ES)?([A-Z0-9]{9})(?![A-Z0-9])/',
            '/(?:^|[^A-Z0-9])ES([A-Z0-9]{9})(?![A-Z0-9])/',
        );
        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $text, $match)) {
                return strtoupper($match[1]);
            }
        }
        if (preg_match('/^\s*([A-Z0-9]{9})\s*$/', $text, $match)) {
            return strtoupper($match[1]);
        }
        return '';
    }

    private static function cleanCertificateName($name)
    {
        $name = trim((string) $name);
        $name = preg_replace('/\s*[\-\/,]\s*(?:NIF|CIF|DNI|IDCES)\s*[:\-]?\s*(?:ES)?[A-Z0-9]{9}\s*$/iu', '', $name);
        return trim((string) $name);
    }

    private static function firstPemBlock($raw, $label)
    {
        if (preg_match(
            '/-----BEGIN ' . preg_quote($label, '/') . '-----.*?-----END '
            . preg_quote($label, '/') . '-----/s',
            $raw,
            $matches
        )) {
            return $matches[0];
        }
        return '';
    }

    private static function findPrivateKeyBlock($raw)
    {
        foreach (array('PRIVATE KEY', 'ENCRYPTED PRIVATE KEY', 'RSA PRIVATE KEY', 'EC PRIVATE KEY') as $label) {
            $block = self::firstPemBlock($raw, $label);
            if ($block !== '') {
                return $block;
            }
        }
        return '';
    }
}
