<?php
/**
 * @file plugins/importexport/safeNativeExport/SafeNativeExportPlugin.inc.php
 *
 * Fault-tolerant Native XML exporter for OJS 3.3.x.
 *
 * This plugin intentionally performs repairs only in memory. It does not
 * persist changes to journal data. It exports each entity separately so that
 * one damaged record does not abort a whole batch.
 *
 * SPDX-License-Identifier: GPL-3.0-or-later
 */

import('lib.pkp.classes.plugins.ImportExportPlugin');

class SafeNativeExportPlugin extends ImportExportPlugin {

    /** @var array */
    private $repairLog = array();

    /** @var array */
    private $runtimeWarnings = array();

    /** @var array */
    private $seenObjects = array();

    /**
     * @copydoc Plugin::register()
     */
    public function register($category, $path, $mainContextId = null) {
        $success = parent::register($category, $path, $mainContextId);
        $this->addLocaleData();
        return $success;
    }

    public function getName() {
        return 'SafeNativeExportPlugin';
    }

    public function getDisplayName() {
        return __('plugins.importexport.safeNative.displayName');
    }

    public function getDescription() {
        return __('plugins.importexport.safeNative.description');
    }

    public function getPluginSettingsPrefix() {
        return 'safeNativeExport';
    }

    /**
     * Main management UI.
     */
    public function display($args, $request) {
        parent::display($args, $request);
        $op = array_shift($args);
        if (!$op) {
            $op = 'index';
        }

        switch ($op) {
            case 'index':
                $templateMgr = TemplateManager::getManager($request);
                $templateMgr->assign(array(
                    'exportUrl' => $request->url(null, null, null, array('plugin', $this->getName(), 'export')),
                    'csrfToken' => $request->getSession()->getCSRFToken(),
                ));
                $templateMgr->display($this->getTemplateResource('index.tpl'));
                return;

            case 'export':
                if (!$request->checkCSRF()) {
                    throw new Exception('CSRF mismatch');
                }
                return $this->handleExport($request);

            default:
                $request->getDispatcher()->handle404();
        }
    }

    /**
     * Execute an export request and download the resulting XML.
     */
    private function handleExport($request) {
        $context = $request->getContext();
        $user = $request->getUser();
        if (!$context || !$user) {
            throw new Exception('Journal context or user is not available.');
        }

        $entityType = $request->getUserVar('entityType') === 'articles' ? 'articles' : 'issues';
        $exportAll = (bool) $request->getUserVar('exportAll');
        $sanitize = (bool) $request->getUserVar('sanitize');
        $retry = (bool) $request->getUserVar('retry');
        $noEmbed = (bool) $request->getUserVar('noEmbed');

        $ids = $exportAll
            ? $this->getAllPublishedIds($entityType, $context->getId())
            : $this->parseIds((string) $request->getUserVar('ids'));

        $ids = array_values(array_unique(array_filter(array_map('intval', $ids), function($id) {
            return $id > 0;
        })));

        $report = array(
            'requested' => count($ids),
            'exported' => 0,
            'skipped' => array(),
            'repairs' => array(),
            'warnings' => array(),
        );

        $doc = new DOMDocument('1.0', 'UTF-8');
        $doc->formatOutput = true;
        $rootName = $entityType === 'issues' ? 'issues' : 'articles';
        $root = $doc->createElementNS('http://pkp.sfu.ca', $rootName);
        $root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance');
        $root->setAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'xsi:schemaLocation', 'http://pkp.sfu.ca native.xsd');
        $doc->appendChild($root);

        foreach ($ids as $id) {
            $this->repairLog = array();
            $this->runtimeWarnings = array();
            $this->seenObjects = array();

            try {
                $singleXml = $this->exportSingleNative($entityType, $id, $context, $user, $sanitize, $noEmbed);
                $singleXml = $this->cleanXmlDocumentString($singleXml);
                $nodes = $this->extractNativeNodes($singleXml, $entityType);

                if (empty($nodes) && $retry) {
                    $singleXml = $this->aggressiveXmlRepair($singleXml);
                    $nodes = $this->extractNativeNodes($singleXml, $entityType);
                }

                if (empty($nodes)) {
                    throw new Exception('Native XML tidak dapat diparse setelah proses sanitasi.');
                }

                foreach ($nodes as $node) {
                    $root->appendChild($doc->importNode($node, true));
                }

                $report['exported']++;
                if (!empty($this->repairLog)) {
                    $report['repairs'][$id] = $this->repairLog;
                }
                if (!empty($this->runtimeWarnings)) {
                    $report['warnings'][$id] = $this->runtimeWarnings;
                }
            } catch (Throwable $e) {
                $report['skipped'][$id] = $this->safeErrorMessage($e);
            } catch (Exception $e) {
                // Kept for older PHP runtimes used by some OJS 3 installations.
                $report['skipped'][$id] = $this->safeErrorMessage($e);
            }
        }

        $this->prependReportComment($doc, $report, $entityType);
        $xml = $doc->saveXML();
        $xml = $this->cleanXmlDocumentString($xml);

        // Final syntax check. This should never abort the download.
        libxml_use_internal_errors(true);
        $check = new DOMDocument();
        if (!$check->loadXML($xml, LIBXML_PARSEHUGE)) {
            $xml = $this->buildEmergencyXml($entityType, $report);
        }
        libxml_clear_errors();

        import('lib.pkp.classes.file.FileManager');
        $fileManager = new FileManager();
        $fileName = $this->getExportFileName(
            $this->getExportPath(),
            'safe-' . $entityType,
            $context,
            '.xml'
        );
        $fileManager->writeFile($fileName, $xml);
        $fileManager->downloadByPath($fileName);
        $fileManager->deleteByPath($fileName);
    }

    /**
     * Export a single entity using the same Native XML filter groups used by OJS.
     */
    private function exportSingleNative($entityType, $id, $context, $user, $sanitize, $noEmbed) {
        PluginRegistry::loadCategory('importexport', true, $context->getId());

        // NativeImportExportDeployment is loaded by the built-in Native plugin in
        // normal installations. Import it explicitly as a fallback.
        if (!class_exists('NativeImportExportDeployment')) {
            import('plugins.importexport.native.NativeImportExportDeployment');
        }
        if (!class_exists('NativeImportExportDeployment')) {
            throw new Exception('Native Import/Export plugin OJS tidak tersedia atau belum lengkap.');
        }

        $filterDao = DAORegistry::getDAO('FilterDAO');
        $opts = array();
        if ($noEmbed) {
            $opts['no-embed'] = true;
        }

        if ($entityType === 'articles') {
            $submission = Services::get('submission')->get($id);
            if (!$submission || (int) $submission->getData('contextId') !== (int) $context->getId()) {
                throw new Exception('Artikel tidak ditemukan pada jurnal ini. ID=' . $id);
            }

            if ($sanitize) {
                $this->sanitizeDataObjectRecursive($submission, 'article[' . $id . ']');
            }

            $filters = $filterDao->getObjectsByGroup('article=>native-xml');
            if (count($filters) !== 1) {
                throw new Exception('Filter article=>native-xml tidak ditemukan atau jumlah filter tidak valid.');
            }
            $filter = array_shift($filters);
            $filter->setDeployment(new NativeImportExportDeployment($context, $user));
            $filter->setOpts($opts);

            return $this->executeFilterSafely($filter, array($submission));
        }

        $issueDao = DAORegistry::getDAO('IssueDAO');
        $issue = $issueDao->getById($id, $context->getId());
        if (!$issue) {
            throw new Exception('Issue tidak ditemukan pada jurnal ini. ID=' . $id);
        }

        if ($sanitize) {
            $this->sanitizeDataObjectRecursive($issue, 'issue[' . $id . ']');
        }

        $filters = $filterDao->getObjectsByGroup('issue=>native-xml');
        if (count($filters) !== 1) {
            throw new Exception('Filter issue=>native-xml tidak ditemukan atau jumlah filter tidak valid.');
        }
        $filter = array_shift($filters);
        $filter->setDeployment(new NativeImportExportDeployment($context, $user));
        $filter->setOpts($opts);

        return $this->executeFilterSafely($filter, array($issue));
    }

    /**
     * Run a Native filter while collecting warnings/notices instead of letting
     * them break the entire batch.
     */
    private function executeFilterSafely($filter, $objects) {
        $oldHandler = set_error_handler(function($severity, $message, $file, $line) {
            if (!(error_reporting() & $severity)) {
                return false;
            }
            $this->runtimeWarnings[] = $this->cleanText(
                $message . ' @ ' . basename($file) . ':' . (int) $line
            );
            return true;
        });

        libxml_use_internal_errors(true);
        try {
            $result = $filter->execute($objects, true);
            if (!$result || !is_object($result) || !method_exists($result, 'saveXml')) {
                throw new Exception('Filter Native XML tidak menghasilkan DOM yang valid.');
            }
            $xml = $result->saveXml();

            foreach (libxml_get_errors() as $error) {
                if ($error->level === LIBXML_ERR_ERROR || $error->level === LIBXML_ERR_FATAL) {
                    $this->runtimeWarnings[] = trim($error->message) . ' line ' . (int) $error->line;
                }
            }
            libxml_clear_errors();
            return $xml;
        } finally {
            libxml_clear_errors();
            if ($oldHandler !== null) {
                restore_error_handler();
            } else {
                restore_error_handler();
            }
        }
    }

    /**
     * Recursively sanitize every value exposed by DataObject::getAllData().
     * This changes only the in-memory object instance.
     */
    private function sanitizeDataObjectRecursive($object, $path) {
        if (!is_object($object)) {
            return;
        }

        $hash = spl_object_hash($object);
        if (isset($this->seenObjects[$hash])) {
            return;
        }
        $this->seenObjects[$hash] = true;

        if (method_exists($object, 'getAllData') && method_exists($object, 'setAllData')) {
            $data = $object->getAllData();
            $data = $this->sanitizeValue($data, $path);
            $object->setAllData($data);
        }
    }

    private function sanitizeValue($value, $path) {
        if (is_string($value)) {
            $clean = $this->cleanText($value);
            if ($clean !== $value) {
                $this->repairLog[] = $path . ': string dibersihkan';
            }
            return $clean;
        }

        if (is_array($value)) {
            $out = array();
            foreach ($value as $key => $item) {
                $out[$key] = $this->sanitizeValue($item, $path . '.' . (string) $key);
            }
            return $out;
        }

        if (is_object($value)) {
            $this->sanitizeDataObjectRecursive($value, $path . '.' . get_class($value));
            return $value;
        }

        return $value;
    }

    /**
     * Normalize a string for UTF-8 and XML 1.0.
     */
    private function cleanText($value) {
        if (!is_string($value) || $value === '') {
            return $value;
        }

        // Remove NUL early because it is a common cause of XML failures.
        $value = str_replace("\0", '', $value);

        // Discard malformed UTF-8 bytes without introducing replacement markup.
        if (function_exists('iconv')) {
            $converted = @iconv('UTF-8', 'UTF-8//IGNORE', $value);
            if ($converted !== false) {
                $value = $converted;
            }
        } elseif (function_exists('mb_convert_encoding')) {
            $value = @mb_convert_encoding($value, 'UTF-8', 'UTF-8');
        }

        // XML 1.0 valid character range.
        $cleaned = @preg_replace(
            '/[^\x{9}\x{A}\x{D}\x{20}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/u',
            '',
            $value
        );
        if ($cleaned !== null) {
            $value = $cleaned;
        }

        return $value;
    }

    /**
     * Sanitize already-rendered XML without changing valid entities.
     */
    private function cleanXmlDocumentString($xml) {
        $xml = $this->cleanText((string) $xml);
        // Repair bare ampersands. Existing XML entities are left intact.
        $fixed = @preg_replace('/&(?!#\d+;|#x[0-9A-Fa-f]+;|[A-Za-z_:][A-Za-z0-9_.:-]*;)/', '&amp;', $xml);
        return $fixed !== null ? $fixed : $xml;
    }

    /**
     * Second-pass repair used only when normal parsing fails.
     */
    private function aggressiveXmlRepair($xml) {
        $xml = $this->cleanXmlDocumentString($xml);
        // Remove invalid numeric references for XML 1.0 control characters.
        $xml = preg_replace_callback('/&#(?:x([0-9A-Fa-f]+)|(\d+));/', function($m) {
            $cp = isset($m[1]) && $m[1] !== '' ? hexdec($m[1]) : intval($m[2]);
            $valid = $cp === 0x9 || $cp === 0xA || $cp === 0xD ||
                ($cp >= 0x20 && $cp <= 0xD7FF) ||
                ($cp >= 0xE000 && $cp <= 0xFFFD) ||
                ($cp >= 0x10000 && $cp <= 0x10FFFF);
            return $valid ? $m[0] : '';
        }, $xml);
        return $xml;
    }

    /**
     * Parse a Native XML fragment and return article/issue elements.
     */
    private function extractNativeNodes($xml, $entityType) {
        libxml_use_internal_errors(true);
        $tmp = new DOMDocument('1.0', 'UTF-8');
        $ok = $tmp->loadXML($xml, LIBXML_PARSEHUGE | LIBXML_NOERROR | LIBXML_NOWARNING);
        if (!$ok || !$tmp->documentElement) {
            libxml_clear_errors();
            return array();
        }

        $wanted = $entityType === 'issues' ? 'issue' : 'article';
        $nodes = array();
        $root = $tmp->documentElement;

        if ($root->localName === $wanted) {
            $nodes[] = $root;
        } else {
            foreach ($root->childNodes as $child) {
                if ($child instanceof DOMElement && $child->localName === $wanted) {
                    $nodes[] = $child;
                }
            }
        }
        libxml_clear_errors();
        return $nodes;
    }

    private function getAllPublishedIds($entityType, $contextId) {
        $ids = array();

        if ($entityType === 'issues') {
            $dao = DAORegistry::getDAO('IssueDAO');
            $result = $dao->getPublishedIssues($contextId);
            while ($item = $result->next()) {
                $ids[] = (int) $item->getId();
            }
            return $ids;
        }

        $dao = DAORegistry::getDAO('SubmissionDAO');
        $result = $dao->getExportable($contextId);
        while ($item = $result->next()) {
            $ids[] = (int) $item->getId();
        }
        return $ids;
    }

    private function parseIds($input) {
        if (!$input) {
            return array();
        }
        return preg_split('/[^0-9]+/', $input, -1, PREG_SPLIT_NO_EMPTY);
    }

    private function safeErrorMessage($e) {
        $message = method_exists($e, 'getMessage') ? $e->getMessage() : 'Unknown export error';
        $message = $this->cleanText((string) $message);
        if (strlen($message) > 500) {
            $message = substr($message, 0, 500) . '...';
        }
        return $message;
    }

    private function prependReportComment($doc, $report, $entityType) {
        $lines = array();
        $lines[] = 'Safe Native XML Export report';
        $lines[] = 'type=' . $entityType;
        $lines[] = 'requested=' . (int) $report['requested'];
        $lines[] = 'exported=' . (int) $report['exported'];
        $lines[] = 'skipped=' . count($report['skipped']);

        foreach ($report['skipped'] as $id => $message) {
            $lines[] = 'SKIPPED ID ' . (int) $id . ': ' . $message;
        }
        foreach ($report['repairs'] as $id => $items) {
            $lines[] = 'REPAIRED ID ' . (int) $id . ': ' . count($items) . ' value(s)';
        }
        foreach ($report['warnings'] as $id => $items) {
            $lines[] = 'WARNING ID ' . (int) $id . ': ' . count($items) . ' warning(s)';
        }

        $text = implode("\n", $lines);
        $text = str_replace('--', '- -', $this->cleanText($text));
        $comment = $doc->createComment("\n" . $text . "\n");
        $doc->insertBefore($comment, $doc->documentElement);
    }

    /**
     * Last-resort output. It is syntactically valid XML and contains the
     * diagnostic report, even if no Native entity could be retained.
     */
    private function buildEmergencyXml($entityType, $report) {
        $rootName = $entityType === 'issues' ? 'issues' : 'articles';
        $doc = new DOMDocument('1.0', 'UTF-8');
        $doc->formatOutput = true;
        $root = $doc->createElementNS('http://pkp.sfu.ca', $rootName);
        $root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance');
        $root->setAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'xsi:schemaLocation', 'http://pkp.sfu.ca native.xsd');
        $doc->appendChild($root);
        $this->prependReportComment($doc, $report, $entityType);
        return $doc->saveXML();
    }

    public function usage($scriptName) {
        echo "SafeNativeExportPlugin currently provides the management UI exporter.\n";
    }

    public function executeCLI($scriptName, &$args) {
        $this->usage($scriptName);
    }
}
