#!/usr/bin/env php
<?php
/**
 * eTemplate2 XML schema
 * 1. we read the legacy etemplate2.dtd converted by PHPStorm (Tools > XML Actions > Convert Schema) to Relax NG with an XML parser
 * 2. remove the obsolete widgets
 * 3. add new widgets from components.json file generated by our documentation build (and enhanced with attributes inherited from Shoelace )
 * 4. apply overwrites below
 * 5. output it again as new eTemplate2 Relax NG schema
 * 6. convert it again via PHPStorm (Tools > XML Actions > Convert Schema) to etemplate2.0.dtd referenced in our xet files
 *    (until we figure out how to use RELAX NG direct)
 *
 * Open problems:
 * - components.json has no information about element hierarchy: not all elements are allowed be contained by an arbitrary element
 * - Relax NG can define attribute types, need to check how with match our internal types to the xet attributes
 *
 * @link https://en.wikipedia.org/wiki/RELAX_NG RELAX NG (REgular LAnguage for XML Next Generation)
 * @link https://relaxng.org/tutorial-20011203.html RELAX NG Tutorial
 * @link https://github.com/EGroupware/etemplate/blob/6767672516524444847207d50b21ba59ff7f1540/js/widget_browser.js old widget-browser dtd generation
 * @link https://www.jetbrains.com/help/phpstorm/validating-web-content-files.html JetBrains DTD, XML-Schema or RelaxNG support
 */
if (!file_exists($file=__DIR__."/etemplate2/_data/components.json"))
{
	die("Missing '$file file!");
}
if (!($components=json_decode(file_get_contents($file), true)))
{
	die("Could not load '$file'!");
}
if (!file_exists(($file = __DIR__."/etemplate2/etemplate2.rng")))
{
	die("Missing file '$file', you need to generate it from 'etemplate2.dtd' e.g. with PHPStorm!");
}
$grammar = new SimpleXMLElement(file_get_contents($file));
$widgets_choice = getByName($grammar, 'Widgets')->choice;
/**
 * Manually overwriting problems / errors in what we automatically generate
 * Use class-name (e.g. Et2Button) so all descends inherit the fix, use-tag to fix only specific widget.
 * @todo fix in TS sources
 */
$overwrites = [
	// RE to remove no longer used legacy widgets not matching "et2-<legacy-name>"
	'.remove' => '/^(button|dropdown_button|int|float|menu|select|taglist|tree|passwd|date|time|ajax_select|vfs-(select|path))/',
	'*' => [    // all widgets, DOM attributes are NOT reported
		'.attrs' => [
			'id' => 'string',   // commented out with some reasoning in Et2Widget
			'width'  => 'string',
			'height' => 'string',
			'slot' => 'string', // would be nice, if we could list parent slots ...
            'style' => 'string',
			'span' => "'all' | '2' | '3' | '4'",    // eT2 grid span
		],
	],
	'Et2InputWidget' => [
		'.attrs' => [
			'tabindex' => 'int',    // not reported, probably because DOM attributeq
		],
	],
	'et2-textbox' => [
			'.children' => ['.quantity' => 'optional', 'et2-image'],
	],
	'et2-date' => [
		'.attrs' => [
			'yearRange' => 'string',
			'dataFormat' => 'string',
		],
	],
	'et2-hbox' => [
		'.children' => 'Widgets',
	],
	'et2-vbox' => [
		'.children' => 'Widgets',
	],
	'et2-box' => [
		'.children' => 'Widgets',
	],
	'Et2Box' => [   // inherited by et2-(v|h)box too
		'.attrs' => [
			'overflow' => 'string', // DOM attributes
		],
	],
	'et2-tabbox' => [
		'.children' => ['tabs','tabpanels'],  // add legacy children tabs and tabpanels
		'.attrs' => [
			'cfDisabled' => 'boolean',  // implemented on server-side
			'cfTypeFilter' => 'string',
			'cfPrivateTab' => 'boolean',
			'cfPrepend' => 'string',
			'cfExclude' => 'string',
		],
	],
	'et2-tab' => null,  // remove/skip, as we currently use legacy tabs and tabpanels
	'et2-tab-panel' => null,
	'et2-details' => [
		'.children' => 'Widgets',
	],
	'et2-groupbox' => [
		'.children' => 'Widgets',
	],
	'et2-split' => [
		'.children' => 'Widgets',
	],
	'et2-url-email' => [
		'.attrs' => [
			'emailDisplay' => 'string', // can't see that anywhere in the code, but in addressbook.index.xet
		],
	],
	'et2-nextmatch-header-custom' => [
		'.attrs' => [
			'emptyLabel' => 'string',
		],
	],
	'Et2Button' => [
		'.attrs' => [
			'image' => 'string',
			'noSubmit' => 'boolean',
			'hideOnReadonly' => 'boolean',
		],
	],
	'Et2ButtonIcon' => 'Et2Button',     // no inheritance from Et2Button, but Et2ButtonMixin, which is not recognised
	'Et2ButtonScroll' => 'Et2Button',
	'Et2Select' => [
		'.attrs' => [
			'rows' => 'int',
			'tabindex' => 'int',
			'allowFreeEntries' => 'boolean',
		],
	],
	'et2-select' => [
		'.children' => ['.quantity' => 'zeroOrMore', 'option'],
	],
	'et2-email' => [
		'.attrs' => [
			'onTagClick' => 'function',
			'multiple' => 'boolean',
		],
	],
];

/**
 * Fixes on the existing legacy DTD
 */
// make overlay the only allowed start element
removeNode($grammar->start->choice);
$grammar->start->addChild('ref')->addAttribute('name', 'overlay');
// fix legacy widgets: attribute-name => (array of) widgets
$missing_legacy_attributes = [
	'app' => 'customfields-types',
    'callback' => 'vfs-upload',
	'class' => ['nextmatch','nextmatch-header', 'nextmatch-customfields', 'nextmatch-sortheader', 'customfields-types'],
	'disabled' => 'nextmatch',
	'exclude' => 'customfields',
	'id' => [
		'.optional' => false,
		'nextmatch-header', 'nextmatch-sortheader', 'nextmatch-customfields', 'nextmatch', 'customfields-types',
	],
	'header_left' => 'nextmatch',
	'header_right' => 'nextmatch',
	'header_row' => 'nextmatch',
	'label' => [
		'.optional' => false,
		'nextmatch-header', 'nextmatch-sortheader',
	],
	'maxWidth' => 'column',
	'minWidth' => 'column',
	'onchange'  => 'customfields-types',
	'onselect' => 'nextmatch',
    'value' => 'option',
	'readonly' => 'customfields-types',
    'sortmode' => [
        '.values' => ['ASC', 'DESC'],
        '.default' => 'ASC',
	    'nextmatch-sortheader',
    ],
	'span' => ['nextmatch', 'nextmatch-header', 'nextmatch-customfields', 'nextmatch-sortheader', 'customfields-types'],
	'statustext' => ['tab', 'customfields-types', 'option'],
	'template' => ['.optional' => false, 'nextmatch'],
	'tab'     => 'customfields',
];
foreach($missing_legacy_attributes as $attribute => $widgets)
{
    foreach((array)$widgets as $key => $widget)
    {
        if (!is_int($key)) continue;	// .(values|default)
        // widget not found add it plus it's attribute-list
        if (!getByName($grammar, $widget))
        {
	        $widgets_choice->addChild('ref')->addAttribute('name', $widget);
	        ($define = $grammar->addChild('define'))->addAttribute('name', $widget);
	        ($element = $define->addChild('element'))->addAttribute('name', $widget);
            $element->addChild('ref')->addAttribute('name', 'attlist.'.$widget);
            $element->addChild('empty');	// no children allowed
	        $grammar->addChild('define')->addAttribute('name', 'attlist.'.$widget);
        }
        // add (optional) attribute
        if (!is_array($widgets) || (!isset($widgets['.optional']) || $widgets['.optional'] === true))
        {
	        $attr = getByName($grammar, 'attlist.'.$widget)->addChild('optional')
	        	->addChild('attribute');
        }
        else
        {
	        $attr = getByName($grammar, 'attlist.'.$widget)->addChild('attribute');
        }
 	    $attr->addAttribute('name', $attribute);
        // add values and/or default
        if (is_array($widgets) && isset($widgets['.values']))
        {
            $choice = $attr->addChild('choice');
            foreach($widgets['.values'] as $value)
            {
                $choice->addChild('value', $value);
            }
        }
	    if (is_array($widgets) && isset($widgets['.default']))
	    {
            $attr->addAttribute('a:defaultValue', $widgets['.default'], 'http://relaxng.org/ns/compatibility/annotations/1.0');
	    }
    }
}

// build a hashed version of all classes, members and attributes to e.g. find ancestors
$classes = [];
foreach($components as $component)
{
    foreach (['members', 'attributes', 'properties'] as $collection)
    {
        foreach ($component[$collection] ?? [] as $key => $element)
        {
            if (!empty($element['name']))
            {
                $component[$collection][$element['name']] = $element;
            }
            unset($component[$collection][$key]);
        }
    }
    $classes[$component['name']] = $component;
}

// iterate of custom-elements to define in the schema
foreach($components as $component)
{
    if (empty($component['tagName']) ||
        preg_match('/_(ro|mobile)$/', $component['tagName']) ||
        array_key_exists($component['tagName'], $overwrites) && !isset($overwrites[$component['tagName']]))
    {
        continue;
    }

    // add the element
    $define = $grammar->addChild('define');
    $define->addAttribute('name', $component['tagName']);
    $element = $define->addChild('element');
    $element->addAttribute('name', $component['tagName']);
    $attrs = $element->addChild('ref');
    $attrs->addAttribute('name', 'attlist.'.$component['tagName']);
    // add to widgets
    $widgets_choice->addChild('ref')->addAttribute('name', $component['tagName']);

    // add the element-attributes
    $attrs = $grammar->addChild('define');
    $attrs->addAttribute('name', 'attlist.'.$component['tagName']);
    $attrs->addAttribute('combine', 'interleave');
    attributes($component, $attrs);

    // add or disallow children depending on overwrites (not available from the TS sources)
    // ToDo: this ignores the use in slots!
    if (empty($overwrites[$component['tagName']]['.children']))
    {
        // don't allow children
        $element->addChild('empty');
    }
    else
    {
        $children = (array)$overwrites[$component['tagName']]['.children'];
        $list = $element->addChild($children['.quantity'] ?? 'oneOrMore');    // zeroOrMore for e.g. empty boxes?
        unset($children['.quantity']);
        // add allowed children
        foreach($children as $child)
        {
            $list->addChild('ref')->addAttribute('name', $child);
        }
    }

    // remove corresponding legacy widget
    removeWidget(str_replace('et2-', '', $component['tagName']));
}

$remove = [];
foreach($widgets_choice->children() as $widget)
{
	if (preg_match($overwrites['.remove'], $name=(string)$widget->attributes()['name']))
	{
		$remove[] = $name;  // removing direct disturbs the foreach!
	}
}
foreach($remove as $name)
{
	removeWidget($name);
}

$dom = new DOMDocument("1.0");
$dom->preserveWhiteSpace = false;
$dom->formatOutput = true;
$dom->loadXML($grammar->asXML());
if (php_sapi_name() !== "cli")
{
	header('Content-Type: application/xml; charset=utf-8');
}
// add <value>1</value> to legacy widget boolean attributes
echo preg_replace('#<choice>
(\s+)<value>true</value>
(\s+)<value>false</value>
(\s+)</choice>#', "<choice>\n\$1<value>false</value>\n\$1<value>true</value>\n\$1<value>1</value>\n\$3</choice>",
	// update the header
	preg_replace('#<!--.*-->#s', '<!--
    ==========================================================
    	EGroupware: eTemplate 2.0 DTD
    	AUTHOR: Hadi Nategh <hn[AT]egroupware.org>, Ralf Becker <rb[AT]egroupware.org>
    	COPYRIGHT: 2016-2024 by EGroupware GmbH
    	LICENSE: https://opensource.org/licenses/gpl-license.php GPL - GNU General Public License Version 2+
        PUBLIC ID: "https://www.egroupware.org/etemplate2.0.dtd"
    	Version: 1.2
    ==========================================================
    
    ==========================================================
        An example how to use this DTD from your XML document:
    
        <?xml version="1.0" encoding="UTF-8"?>
    	<!DOCTYPE overlay PUBLIC "-//EGroupware GmbH//eTemplate 2//EN" "https://www.egroupware.org/etemplate2.0.dtd">
        <overlay>
        ...
        </overlay>
    ==========================================================
-->', $dom->saveXML()));

/**
 * Remove (legacy-)widget with given name from schema
 *
 * @param string $name
 * @return void
 */
function removeWidget(string $name)
{
	global $grammar, $widgets_choice;
	if (removeByName($widgets_choice, $name))
	{
		removeByName($grammar, $name);
		removeByName($grammar, 'attlist.'.$name);

	}
}

function removeByName(SimpleXMLElement $parent, string $name) : bool
{
	foreach($parent->children() as $child)
	{
		if ((string)$child->attributes()['name'] === $name)
		{
			removeNode($child);
			return true;
		}
	}
	return false;
}

function removeNode(SimpleXMLElement $node)
{
	$dom=dom_import_simplexml($node);
	$dom->parentNode->removeChild($dom);
}

function getByName(SimpleXMLElement $parent, string $name) : ?SimpleXMLElement
{
	foreach($parent as $element)
	{
		if ((string)$element->attributes()['name'] === $name)
		{
			return $element;
		}
	}
	return null;
}

/**
 * Overwrite attributes in given element / class
 * @param array& $element
 * @param string|null $name overwrites to use e.g. "*", default use $element['name']
 * @return void
 */
function overwriteAttributes(array& $element, string $name=null)
{
	global $overwrites;

	if (!isset($name)) $name = $element['tagName'];

	// forward just to another widget, class or mixin
	if (isset($overwrites[$name]) && is_string($overwrites[$name]))
	{
		$name = $overwrites[$name];
	}
	if (empty($overwrites[$name]['.attrs']))
	{
		return; // nothing to do
	}
	foreach($overwrites[$name]['.attrs'] as $attr => $type)
	{
		if (isset($type))
		{
            // only add it, if not already there
            if (!array_filter($element['attributes']??[], static function($attribute) use ($attr)
            {
                return isset($attribute) && $attribute['name'] === $attr;
            }))
            {
                $element['attributes'][] = ['name' => $attr, 'type' => ['text' => $type]];
            }
		}
        // remove attribute set to NULL in overwrites
		elseif (isset($element['attributes']))
		{
            $element['attributes'] = array_filter($element['attributes'], static function($attribute) use($attr)
            {
                return isset($attribute) && $attribute['name'] !== $attr;
            });
		}
	}
}

/**
 * Generate attribute list for an element
 *
 * @param array $component class defining the element
 * @param SimpleXMLElement|null $attrs attribute list element: <define name="attlist.<element>" combine="interleave"/>
 * @return string[]|void
 */
function attributes(array $component, ?SimpleXMLElement $attrs=null)
{
	overwriteAttributes($component, '*');
	overwriteAttributes($component);
	// also apply overwrites of own class, direct parent and mixins
	foreach(getAncestors($component) as $parent)
	{
		if ($parent && !empty($parent['name']) && preg_match('/^Et2/', $parent['name']))  // can also be Lit or Sl*
		{
			overwriteAttributes($component, $parent['name']);
		}
	}
	$attributes = array_filter($component['attributes'] ?? [], static function ($attr)
	{
		return ($attr['name'] ?? null) && $attr['name'][0] !== '_'; // ignore attributes with empty name or name starting with underscore
	});
    usort($attributes, static function ($a, $b) {
        return strcasecmp($a['name'], $b['name']);
    });

	if (!isset($attrs))
	{
		return array_map(static function($attr) use ($component)
		{
			return $attr['name'].'('.($attr['type']['text']??'any').
				(isset($attr['fieldName']) && isset($component['members'][$attr['fieldName']]['default']) ?
					':'.$component['members'][$attr['fieldName']]['default'] : '').')';
		}, $attributes);
	}
	foreach($attributes as $attr)
	{
		// todo: are all attributes optional, probably
		$optional = $attrs->addChild('optional');
		$attribute = $optional->addChild('attribute');
		$attribute->addAttribute('name', $attr['name']);
		if (isset($attr['fieldName']) && isset($component['members'][$attr['fieldName']]['default']))
		{
			$default = $component['members'][$attr['fieldName']]['default'];
			if (in_array($default[0], ['"', "'"]) && $default[0] === substr($default, -1))
			{
				$default = substr($default, 1, -1);
			}
            if ($default !== 'undefined')   // do NOT add undefined, it's the default anyway
            {
                $attribute->addAttribute('a:defaultValue', $default, 'http://relaxng.org/ns/compatibility/annotations/1.0');
            }
		}
		switch ($attr['type']['text'] ?? 'any')
		{
			case 'boolean':
				$choice = $attribute->addChild('choice');
				$choice->addChild('value', 'false');
				$choice->addChild('value', 'true');
				$choice->addChild('value', '1');    // often used in our templates
				// not understood by DTD :(
				//$choice->addChild('text');  // as we allow "@<attr>" or "$cont[name]"
				break;
            case 'any':
                break;
			// todo: other types are understood by RELAX NG, but not by DTD
            default:    // distinct values: 'a' | 'b' | 'c'
                if (isset($attr['type']['text']) && $attr['type']['text'][0] === "'" && substr($attr['type']['text'], -1) === "'")
                {
	                $choice = $attribute->addChild('choice');
                    foreach(preg_split("/'\s*\|\s*'/", substr($attr['type']['text'], 1, -1)) as $part)
                    {
	                    $choice->addChild('value', $part);
                    }
                }
                break;
		}
	}
}

/**
 * Get ancestors: superclass(es) and mixins of the current class/element
 *
 * @param array|null $class
 * @return array[] of array with name attribute
 */
function getAncestors(?array $class=null)
{
	if (!isset($class) || empty($class['name']) || !preg_match('/^Et2/', $class['name']))
	{
		return [];
	}
	if (!isset($class['kind']))
	{
		global $classes;
		$class = $classes[$class['name']] ?? null;
	}
	return $class ? array_filter([$class, ...getAncestors($class['superclass']??null), ...$class['mixins']??[]]) : [];
}